diff --git a/packages/demo/package.json b/packages/demo/package.json index ed50074f..742a2cdb 100644 --- a/packages/demo/package.json +++ b/packages/demo/package.json @@ -16,14 +16,18 @@ }, "dependencies": { "@lit/react": "^1.0.5", + "@uiw/react-json-view": "2.0.0-alpha.30", "lucide-react": "^0.427.0", "next": "14.2.10", "react": "^18", - "react-dom": "^18" + "react-dom": "^18", + "reflect-metadata": "^0.2.0" }, "devDependencies": { "@ckb-ccc/connector-react": "workspace:*", "@ckb-ccc/lumos-patches": "workspace:*", + "@ckb-ccc/ssri": "workspace:*", + "@ckb-ccc/udt": "workspace:*", "@ckb-lumos/ckb-indexer": "^0.24.0-next.1", "@ckb-lumos/common-scripts": "^0.24.0-next.1", "@ckb-lumos/config-manager": "^0.24.0-next.1", diff --git a/packages/demo/src/app/connected/(tools)/SSRI/page.tsx b/packages/demo/src/app/connected/(tools)/SSRI/page.tsx new file mode 100644 index 00000000..6359e8ed --- /dev/null +++ b/packages/demo/src/app/connected/(tools)/SSRI/page.tsx @@ -0,0 +1,980 @@ +"use client"; + +import "reflect-metadata"; +import React, { useState, useEffect, useCallback } from "react"; +import { Button } from "@/src/components/Button"; +import { TextInput } from "@/src/components/Input"; +import { useApp } from "@/src/context"; +import { ButtonsPanel } from "@/src/components/ButtonsPanel"; +import { Dropdown } from "@/src/components/Dropdown"; +import { + ScriptAmountArrayInput, + ScriptAmountType, +} from "@/src/components/ScriptAmountInput"; +import { ssri } from "@ckb-ccc/ssri"; +import { ccc } from "@ckb-ccc/connector-react"; +import JsonView from "@uiw/react-json-view"; +import { darkTheme } from "@uiw/react-json-view/dark"; +import Image from "next/image"; +import { HexArrayInput } from "@/src/components/HexArrayInput"; +import { Icon } from "@/src/components/Icon"; + +type ParamValue = + | string + | ScriptAmountType[] + | ccc.ScriptLike + | ccc.CellLike + | ccc.TransactionLike + | boolean + | undefined + | ccc.HexLike[]; + +type MethodParamType = + | "contextScript" + | "contextCell" + | "contextTransaction" + | "scriptAmountArray" + | "scriptArray" + | "tx" + | "signer" + | "hexArray" + | "hex" + | "stringArray"; + +interface MethodParam { + name: string; + type?: MethodParamType; +} + +const SSRI_BUILT_IN_METHODS = ["getMethods", "hasMethods", "version"]; +const MODE_OPTIONS = [ + { + name: "builtin", + displayName: "Built-in SSRI method", + iconName: "Hash" as const, + }, + { + name: "custom", + displayName: "Custom trait and method", + iconName: "Hash" as const, + }, +]; + +type IconName = "Hash" | "Code"; + +const PARAM_TYPE_OPTIONS: { + name: string; + displayName: string; + iconName: IconName; +}[] = [ + { name: "contextScript", displayName: "Context Script", iconName: "Code" }, + { name: "contextCell", displayName: "Context Cell", iconName: "Code" }, + { + name: "contextTransaction", + displayName: "Context Transaction", + iconName: "Code", + }, + { + name: "scriptAmountArray", + displayName: "Script Amount Array", + iconName: "Code", + }, + { name: "scriptArray", displayName: "Script Array", iconName: "Code" }, + { name: "tx", displayName: "Transaction", iconName: "Code" }, + { name: "signer", displayName: "Signer", iconName: "Code" }, + // { name: "hexArray", displayName: "Hex Array", iconName: "Code" }, + { name: "hex", displayName: "Generic Data (HexLike)", iconName: "Code" }, + { + name: "stringArray", + displayName: "String Array (comma-separated)", + iconName: "Code", + }, +]; + +export default function SSRI() { + const { signer, createSender } = useApp(); + const { log, error } = createSender("SSRI"); + + const [SSRIExecutorURL, setSSRIExecutorURL] = useState( + "http://localhost:9090", + ); + const [contractOutPointTx, setContractOutPointTx] = useState(""); + const [contractOutPointIndex, setContractOutPointIndex] = + useState("0"); + const [methodParams, setMethodParams] = useState([]); + const [paramValues, setParamValues] = useState>( + {}, + ); + const [ssriContext, setSSRIContext] = useState({}); + const [methodResult, setMethodResult] = useState(undefined); + const [SSRICallDetails, setSSRICallDetails] = useState(null); + const [iconDataURL, setIconDataURL] = useState(""); + const [ssriContractTypeIDArgs, setSsriContractTypeIDArgs] = useState( + "0x8fd55df879dc6176c95f3c420631f990ada2d4ece978c9512c39616dead2ed56", + ); + const [showSSRICallDetails, setShowSSRICallDetails] = + useState(false); + const [isLoading, setIsLoading] = useState(false); + const [traitName, setTraitName] = useState("SSRI"); + const [methodName, setMethodName] = useState("getMethods"); + const [isBuiltIn, setIsBuiltIn] = useState(true); + const [selectedParamType, setSelectedParamType] = + useState("contextScript"); + const [methodPathInput, setMethodPathInput] = useState(""); + + const addMethodParam = () => { + const contextTypes = ["contextScript", "contextCell", "contextTransaction"]; + const hasContextParam = methodParams.some( + (param) => param.type && contextTypes.includes(param.type), + ); + + if (contextTypes.includes(selectedParamType) && hasContextParam) { + error( + "Invalid Parameter: You can only have one context parameter (Script, Cell, or Transaction)", + ); + return; + } + + setMethodParams([ + ...methodParams, + { + name: `Parameter${methodParams.length}`, + type: selectedParamType, + }, + ]); + }; + + const deleteMethodParam = (index: number) => { + setMethodParams(methodParams.filter((_, i) => i !== index)); + }; + + const getOutPointFromTypeIDArgs = useCallback(async () => { + if (!signer) return; + const scriptCell = await signer.client.findSingletonCellByType({ + codeHash: "0x00000000000000000000000000000000000000000000000000545950455f4944", + hashType: "type", + args: ssriContractTypeIDArgs, + }); + if (!scriptCell) { + throw new Error("PUDT script cell not found"); + } + const targetOutPoint = scriptCell.outPoint; + setContractOutPointTx(targetOutPoint.txHash); + setContractOutPointIndex(targetOutPoint.index.toString()); + }, [signer, ssriContractTypeIDArgs]); + + useEffect(() => { + getOutPointFromTypeIDArgs(); + }, [ssriContractTypeIDArgs, signer, getOutPointFromTypeIDArgs]); + + const makeSSRICall = async () => { + if (!signer) return; + + setIsLoading(true); + setMethodResult(undefined); + setIconDataURL(""); + + const testSSRIExecutor = new ssri.ExecutorJsonRpc(SSRIExecutorURL); + + let contract: ssri.Trait | undefined; + try { + const targetOutPoint = { + txHash: contractOutPointTx, + index: parseInt(contractOutPointIndex), + }; + const scriptCell = await signer.client.getCell(targetOutPoint); + + if (!scriptCell) { + throw new Error("Script cell not found"); + } + + if (!scriptCell.cellOutput.type?.hash()) { + throw new Error("Script cell type hash not found"); + } + contract = new ssri.Trait(scriptCell.outPoint, testSSRIExecutor); + + // Check contract is defined before using + if (!contract) { + throw new Error("Contract not initialized"); + } + + // Initialize context object + let context = {}; + + // Prepare arguments, separating context params from regular args + const args = methodParams + .filter( + (paramType) => + !["contextScript", "contextCell", "contextTransaction"].includes( + paramType.type || "", + ), + ) + .map((paramType, index) => { + let value = paramValues[`Parameter${index}`]; + + if (paramType.type === "signer") { + return signer; + } + if (paramType.type === "scriptAmountArray") { + value = paramValues[`Parameter${index}`] as ScriptAmountType[]; + return value.map((scriptAmount) => ({ + to: scriptAmount.script, + amount: scriptAmount.amount, + })); + } + // ... rest of existing param type handling ... + return value; + }); + + // Handle context parameters separately + methodParams.forEach((paramType, index) => { + const value = paramValues[`Parameter${index}`]; + if (paramType.type === "contextScript") { + context = { script: value as ccc.ScriptLike }; + } else if (paramType.type === "contextCell") { + context = { cell: value as ccc.CellLike }; + } else if (paramType.type === "contextTransaction") { + context = { transaction: value as ccc.TransactionLike }; + } + }); + + setSSRIContext(context); + setSSRICallDetails({ + trait: traitName, + method: methodName, + args: args, + contractOutPoint: { + txHash: contractOutPointTx, + index: parseInt(contractOutPointIndex), + }, + ssriContext: context, + }); + + log( + "Calling", + `${traitName}.${methodName}`, + "on contract at", + String(contractOutPointTx), + "index", + String(contractOutPointIndex), + ); + let result; + if (isBuiltIn) { + // Type-safe way to call built-in methods + switch (methodName) { + case "getMethods": + result = await contract.getMethods(); + break; + case "hasMethods": + result = await contract.hasMethods( + (args[0] as string[]) ?? [], + (args[1] as ccc.HexLike[]) ?? [], + ); + break; + case "version": + result = await contract.version(); + break; + } + } else { + let argsHex = methodParams.map((param, index) => { + const arg = args[index]; + + switch (param.type) { + case "contextScript": + case "contextCell": + case "contextTransaction": + // Context params are handled separately in context object + return "0x"; + + case "scriptAmountArray": + case "scriptArray": + // These are already properly formatted in the args preparation above + return ccc.hexFrom(JSON.stringify(arg)); + + case "tx": + // Transaction data should already be in hex format + return (arg as string) || "0x"; + + case "signer": + // Signer is handled specially in args preparation + return "0x"; + + case "hex": + // Single hex value, should already be 0x-prefixed + return arg as string; + + case "stringArray": + // Array of strings + return ccc.hexFrom(JSON.stringify(arg)); + + default: + throw new Error(`Unsupported parameter type: ${param.type}`); + } + }); + result = await contract + .assertExecutor() + .runScript(contract.code, `${traitName}.${methodName}`, argsHex); + } + if (result) { + if (traitName === "UDT" && methodName === "icon") { + const dataURL = ccc.bytesTo(result.res as string, "utf8"); + setMethodResult(result); + setIconDataURL(dataURL); + } else { + setMethodResult(result); + } + } + } catch (e) { + let errorMessage = + e instanceof Error + ? e.message + : typeof e === "object" + ? "Check your SSRI server" + : String(e) || "Unknown error"; + if (String(errorMessage).length < 3) { + errorMessage = + "Check your SSRI server or URL. Run `docker run -p 9090:9090 hanssen0/ckb-ssri-server` to start a local SSRI server."; + } + setMethodResult(`Error: ${errorMessage}`); + error(`Error: ${errorMessage}`); + } finally { + setIsLoading(false); + } + }; + + return ( +
+
+

+ How to Use: +

+
+
+ + 1 + +
+ + docker run -p 9090:9090 hanssen0/ckb-ssri-server + + to start a local SSRI server. +
+
+ +
+ + 2 + +
+ The default parameters are prepared to just work. Just click{" "} + + Execute Method + {" "} + button at the bottom to call the{" "} + + SSRI.get_methods + {" "} + method. +
+
+ +
+ + 3 + +
+ All Done! You called an SSRI method! Try playing with other + methods while reading{" "} + + [EN/CN] Script-Sourced Rich Information - 来源于 Script 的富信息 + {" "} + to know how to adjust parameters to your need. +
+
+
+
+ <> + +
+ + +
+ + +
+ + { + setIsBuiltIn(value === "builtin"); + if (value === "builtin") { + setTraitName("SSRI"); + setMethodName(SSRI_BUILT_IN_METHODS[0]); + if (methodName === "hasMethods") { + setMethodParams([ + { name: "methodNames", type: "stringArray" }, + { name: "extraMethodPaths", type: "hexArray" }, + ]); + } + } else { + setTraitName(""); + setMethodName(""); + } + }} + className="w-1/4" + /> + + {isBuiltIn ? ( + ({ + name: method, + displayName: method, + iconName: "Code", + }))} + selected={methodName} + onSelect={(value) => { + setMethodName(value); + if (value === "hasMethods") { + setMethodParams([ + { name: "methodNames", type: "stringArray" }, + { name: "extraMethodPaths", type: "hexArray" }, + ]); + } else { + setMethodParams([]); // Clear params for other methods + } + }} + className="flex-1" + /> + ) : ( + <> + + + + )} +
+ + +
+ + setSelectedParamType(value as MethodParamType)} + className="flex-grow" + /> + +
+ + {methodParams.map((param, index) => ( +
+
+ {param.type === "hex" ? ( +
+
+ +
+ { + if (!value.startsWith("0x")) value = "0x" + value; + setParamValues((prev) => ({ + ...prev, + [`Parameter${index}`]: value, + })); + }, + ]} + /> +
+ ) : param.type === "scriptAmountArray" || + param.type === "scriptArray" ? ( + + setParamValues((prev) => ({ + ...prev, + [`Parameter${index}`]: scriptAmounts, + })) + } + showAmount={param.type === "scriptAmountArray"} + /> + ) : param.type === "hexArray" ? ( + + setParamValues((prev) => ({ + ...prev, + [`Parameter${index}`]: hexValues, + })) + } + /> + ) : param.type == "signer" ? ( +
+
+ + {signer && ( + + )} + {!signer && ( + + )} +
+
+ ) : param.type == "contextScript" ? ( +
+
+ +
+
+ + setParamValues((prev) => ({ + ...prev, + [`Parameter${index}`]: { + ...((prev[`Parameter${index}`] as ccc.ScriptLike) || + {}), + codeHash: value, + }, + })), + ]} + /> +
+ + + setParamValues((prev) => ({ + ...prev, + [`Parameter${index}`]: { + ...((prev[`Parameter${index}`] as ccc.ScriptLike) || + {}), + hashType: value, + }, + })) + } + /> +
+ + setParamValues((prev) => ({ + ...prev, + [`Parameter${index}`]: { + ...((prev[`Parameter${index}`] as ccc.ScriptLike) || + {}), + args: value, + }, + })), + ]} + /> +
+
+ ) : param.type == "contextCell" ? ( +
+ + Parameter {index} ({param.type}) + +
+ + setParamValues((prev) => ({ + ...prev, + [`Parameter${index}`]: { + outPoint: { + txHash: "0x", + index: 0, + }, + cellOutput: { + capacity: value, + lock: { + codeHash: "", + hashType: "type" as const, + args: "", + }, + }, + outputData: + (prev[`Parameter${index}`] as ccc.CellLike) + ?.outputData || "", + } as ccc.CellLike, + })), + ]} + /> + + setParamValues((prev) => ({ + ...prev, + [`Parameter${index}`]: { + outPoint: { + txHash: "0x", + index: 0, + }, + cellOutput: { + capacity: + ( + prev[`Parameter${index}`] as ccc.CellLike + )?.cellOutput?.capacity?.toString() || "", + lock: { + codeHash: "", + hashType: "type" as const, + args: "", + }, + }, + outputData: value, + } as ccc.CellLike, + })), + ]} + /> +
+
+ ) : param.type == "contextTransaction" ? ( +
+ + Parameter {index} ({param.type}) + +
+ + setParamValues((prev) => ({ + ...prev, + [`Parameter${index}`]: value, + })), + ]} + /> +
+
+ ) : param.type == "tx" ? ( +
+
+ + +
+ {paramValues[`Parameter${index}NotUsingDefault`] && ( +
+ + setParamValues((prev) => ({ + ...prev, + [`Parameter${index}`]: value, + })), + ]} + /> +
+ )} +
+ ) : param.type === "stringArray" ? ( +
+
+ +
+ + setParamValues((prev) => ({ + ...prev, + [`Parameter${index}`]: value + .split(",") + .map((s) => s.trim()), + })), + ]} + /> +
+ ) : ( + + setParamValues((prev) => ({ + ...prev, + [`Parameter${index}`]: value, + })), + ]} + /> + )} + {param.type === "hexArray" && methodName === "hasMethods" && ( +
+ setMethodPathInput(value), + ]} + className="flex-grow" + /> + +
+ )} +
+ +
+ ))} + + <> +
+ setShowSSRICallDetails(e.target.checked)} + className="rounded border-gray-300" + /> + +
+ + {showSSRICallDetails && ( +
+ + {SSRICallDetails && ( +
+ +
+ )} +
+ )} + + +
+ + {isLoading ? ( +
+
+
+ ) : ( + methodResult && ( +
+ +
+ ) + )} +
+ {!isLoading && iconDataURL && ( +
+ + {""} +
+ )} + + + +
+ ); +} + +const methodParamTypeMap: Record<`${string}.${string}`, MethodParamType> = { + "name.context": "contextScript", + "symbol.context": "contextScript", + "decimals.context": "contextScript", + "totalSupply.context": "contextScript", + "balanceOf.context": "contextScript", + "icon.context": "contextScript", + "transfer.signer": "signer", + "transfer.transfers": "scriptAmountArray", + "transfer.tx": "tx", + "mint.signer": "signer", + "mint.mints": "scriptAmountArray", + "mint.tx": "tx", + "pause.signer": "signer", + "pause.locks": "scriptArray", + "pause.tx": "tx", + "pause.extraLockHashes": "hexArray", + "unpause.signer": "signer", + "unpause.locks": "scriptArray", + "unpause.tx": "tx", + "unpause.extraLockHashes": "hexArray", + "isPaused.locks": "scriptArray", + "isPaused.extraLockHashes": "hexArray", + "SSRI.hasMethods.methodNames": "stringArray", + "SSRI.hasMethods.extraMethodPaths": "hexArray", +}; + +const hiddenMethods = [ + "constructor", + "completeChangeToLock", + "completeBy", + "assertExecutor", + "tryRun", + "hasMethods", + "getMethods", + "version", +]; diff --git a/packages/demo/src/app/connected/(tools)/layout.tsx b/packages/demo/src/app/connected/(tools)/layout.tsx index 6632a401..7af75786 100644 --- a/packages/demo/src/app/connected/(tools)/layout.tsx +++ b/packages/demo/src/app/connected/(tools)/layout.tsx @@ -8,7 +8,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( -
+
{children}
); diff --git a/packages/demo/src/app/connected/page.tsx b/packages/demo/src/app/connected/page.tsx index f97b02a8..772fef66 100644 --- a/packages/demo/src/app/connected/page.tsx +++ b/packages/demo/src/app/connected/page.tsx @@ -47,6 +47,7 @@ const TABS: [ReactNode, string, keyof typeof icons, string][] = [ "text-cyan-600", ], ["Nervos DAO", "/connected/NervosDao", "Vault", "text-pink-500"], + ["SSRI", "/connected/SSRI", "Pill", "text-blue-500"], ["Hash", "/utils/Hash", "Barcode", "text-violet-500"], ["Mnemonic", "/utils/Mnemonic", "SquareAsterisk", "text-fuchsia-500"], ["Keystore", "/utils/Keystore", "Notebook", "text-rose-500"], diff --git a/packages/demo/src/components/HexArrayInput.tsx b/packages/demo/src/components/HexArrayInput.tsx new file mode 100644 index 00000000..21a1734c --- /dev/null +++ b/packages/demo/src/components/HexArrayInput.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { TextInput } from "@/src/components/Input"; +import { Button } from "@/src/components/Button"; + + +interface HexInputProps { + value: string; + onChange: (value: string) => void; + onRemove?: () => void; +} + +interface HexArrayInputProps { + value: string[]; + onChange: (value: string[]) => void; + label?: string; +} + +export const HexInput: React.FC = ({ + value, + onChange, + onRemove, +}) => { + return ( +
+ { + // Ensure hex format + const hexValue = newValue.startsWith("0x") + ? newValue + : `0x${newValue}`; + onChange(hexValue); + }, + ]} + className="w-full" + /> + {onRemove && ( + + )} +
+ ); +}; + +export const HexArrayInput: React.FC = ({ + value = [], + onChange, + label = "Hex Values", +}) => { + const addHexValue = () => { + onChange([...value, "0x"]); + }; + + const removeHexValue = (index: number) => { + const newValues = [...value]; + newValues.splice(index, 1); + onChange(newValues); + }; + + const updateHexValue = (index: number, hexValue: string) => { + const newValues = [...value]; + newValues[index] = hexValue; + onChange(newValues); + }; + + return ( +
+ + {value.map((hexValue, index) => ( + updateHexValue(index, updatedValue)} + onRemove={() => removeHexValue(index)} + /> + ))} + +
+ ); +}; \ No newline at end of file diff --git a/packages/demo/src/components/ScriptAmountInput.tsx b/packages/demo/src/components/ScriptAmountInput.tsx new file mode 100644 index 00000000..c90dd4b9 --- /dev/null +++ b/packages/demo/src/components/ScriptAmountInput.tsx @@ -0,0 +1,202 @@ +import React, { useState, useEffect } from "react"; +import { Button } from "@/src/components/Button"; +import { TextInput } from "@/src/components/Input"; +import { useApp } from "@/src/context"; +import { Dropdown } from "@/src/components/Dropdown"; +import { ccc } from "@ckb-ccc/connector-react"; +import { Icon } from "./Icon"; + +export type ScriptType = { + codeHash: string; + hashType: string; + args: string; +}; + +export type ScriptAmountType = { + script: ScriptType; + amount?: string; +}; + +export interface ScriptAmountArrayInputProps { + value: ScriptAmountType[]; + onChange: (value: ScriptAmountType[]) => void; + label?: string; + showAmount?: boolean; +} + +export interface ScriptAmountInputProps { + value: ScriptAmountType; + onChange: (value: ScriptAmountType) => void; + onRemove?: () => void; + showAmount?: boolean; +} + +export const ScriptAmountInput: React.FC = ({ + value, + onChange, + onRemove, + showAmount = true, +}) => { + const [inputType, setInputType] = useState<"script" | "address">("address"); + const [address, setAddress] = useState(""); + const { signer } = useApp(); + + // Handle address to script conversion + const handleAddressChange = async (newAddress: string) => { + setAddress(newAddress); + if (signer && newAddress) { + try { + const script = (await ccc.Address.fromString(newAddress, signer.client)) + .script; + onChange({ + ...value, + script: { + codeHash: script.codeHash, + hashType: script.hashType, + args: script.args, + }, + }); + } catch (error) { + console.error("Failed to parse address:", error); + } + } + }; + + return ( +
+
+ + setInputType(type as "script" | "address")} + className="flex-grow" + /> +
+ + {inputType === "address" ? ( + + ) : ( + <> + + onChange({ ...value, script: { ...value.script, codeHash } }), + ]} + className="w-full" + /> +
+ + + onChange({ ...value, script: { ...value.script, hashType } }) + } + className="flex-grow" + /> +
+ + onChange({ ...value, script: { ...value.script, args } }), + ]} + className="w-full" + /> + + )} + + {showAmount && ( + onChange({ ...value, amount }), + ]} + className="w-full" + /> + )} + {onRemove && ( + + )} +
+ ); +}; + +export const ScriptAmountArrayInput: React.FC = ({ + value = [], + onChange, + label = "Scripts with Amounts", + showAmount = true, +}) => { + const addScriptAmount = () => { + const newScript = { + script: { codeHash: "", hashType: "type", args: "" }, + ...(showAmount && { amount: "0" }), + }; + onChange([...value, newScript]); + }; + + const removeScriptAmount = (index: number) => { + const newScriptAmounts = [...value]; + newScriptAmounts.splice(index, 1); + onChange(newScriptAmounts); + }; + + const updateScriptAmount = ( + index: number, + scriptAmount: ScriptAmountType, + ) => { + const newScriptAmounts = [...value]; + newScriptAmounts[index] = scriptAmount; + onChange(newScriptAmounts); + }; + + return ( +
+ + {value.map((scriptAmount, index) => ( + + updateScriptAmount(index, updatedScriptAmount) + } + onRemove={() => removeScriptAmount(index)} + showAmount={showAmount} + /> + ))} + +
+ ); +}; diff --git a/packages/demo/tsconfig.json b/packages/demo/tsconfig.json index a7cc17ad..c97e06b1 100644 --- a/packages/demo/tsconfig.json +++ b/packages/demo/tsconfig.json @@ -19,7 +19,9 @@ ], "paths": { "@/*": ["./*"] - } + }, + "emitDecoratorMetadata": true, + "experimentalDecorators": true }, "include": [ "next-env.d.ts",