diff --git a/src/assets/coin-svgrepo-com.svg b/src/assets/coin-svgrepo-com.svg new file mode 100644 index 0000000..0e45b86 --- /dev/null +++ b/src/assets/coin-svgrepo-com.svg @@ -0,0 +1,155 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/constants/chain.ts b/src/constants/chain.ts index 02126ea..17d3e90 100644 --- a/src/constants/chain.ts +++ b/src/constants/chain.ts @@ -46,6 +46,27 @@ export const ContractAddressMap: Record = { 84532: "0x0483e88cd38cccb71c9c1020faf830d905542d09", //baseSepolia }; +export const SBTAddressMap: Record = { + 421614: "0x6889695c0A6272410Af24766bbf3980F2466a1dc", //arbitrumSepolia + 11155420: "0x2e842f6e3fbf093981938f4e24892185bbdad727", //optimismSepolia + 84532: "0x078f00401536a55E973d4a2Cf26a9B1f98544d52", //baseSepolia +}; + +export const SBTBrowserMap: Record< + number, + (id?: number, hash?: string) => string +> = { + 421614: function (id?: number) { + return `https://testnets.opensea.io/zh-CN/assets/arbitrum-sepolia/0x6889695c0a6272410af24766bbf3980f2466a1dc/${id}`; + }, + 11155420: function (_id?: number, hash?: string) { + return `https://optimism-sepolia.blockscout.com/token/${hash}`; + }, + 84532: function (id?: number) { + return `https://testnets.opensea.io/zh-CN/assets/base-sepolia/0x078f00401536a55e973d4a2cf26a9b1f98544d52/${id}`; + }, +}; + export const FaucetMap: Record[]> = { 421614: [ { "Faucet by Alchemy": "https://www.alchemy.com/faucets/arbitrum-sepolia" }, diff --git a/src/constants/contract.ts b/src/constants/contract.ts index 8372dc4..057fdc1 100644 --- a/src/constants/contract.ts +++ b/src/constants/contract.ts @@ -199,3 +199,311 @@ export const ABI = [ type: "function", }, ]; + +export const SBTABI = [ + { inputs: [], stateMutability: "nonpayable", type: "constructor" }, + { inputs: [], name: "AlreadyMint", type: "error" }, + { inputs: [], name: "NotShortGenius", type: "error" }, + { inputs: [], name: "Soulbound", type: "error" }, + { inputs: [], name: "TokenNotExist", type: "error" }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "owner", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "approved", + type: "address", + }, + { + indexed: true, + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "Approval", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "owner", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "operator", + type: "address", + }, + { indexed: false, internalType: "bool", name: "approved", type: "bool" }, + ], + name: "ApprovalForAll", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "recipent", + type: "address", + }, + { + indexed: true, + internalType: "uint256", + name: "tokenID", + type: "uint256", + }, + ], + name: "MintSuccess", + type: "event", + }, + { + anonymous: false, + inputs: [ + { + indexed: true, + internalType: "address", + name: "previousOwner", + type: "address", + }, + { + indexed: true, + internalType: "address", + name: "newOwner", + type: "address", + }, + ], + name: "OwnershipTransferred", + type: "event", + }, + { + anonymous: false, + inputs: [ + { indexed: true, internalType: "address", name: "from", type: "address" }, + { indexed: true, internalType: "address", name: "to", type: "address" }, + { + indexed: true, + internalType: "uint256", + name: "tokenId", + type: "uint256", + }, + ], + name: "Transfer", + type: "event", + }, + { + inputs: [ + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "tokenId", type: "uint256" }, + ], + name: "approve", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "owner", type: "address" }], + name: "balanceOf", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "userAddr", type: "address" }], + name: "checkTokenID", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "id", type: "uint256" }], + name: "checkTokenValid", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "contractURI", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], + name: "getApproved", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "owner", type: "address" }, + { internalType: "address", name: "operator", type: "address" }, + ], + name: "isApprovedForAll", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "userAddr", type: "address" }], + name: "mint", + outputs: [], + stateMutability: "payable", + type: "function", + }, + { + inputs: [], + name: "name", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "owner", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "tokenId", type: "uint256" }], + name: "ownerOf", + outputs: [{ internalType: "address", name: "", type: "address" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "renounceOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "from", type: "address" }, + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "tokenId", type: "uint256" }, + ], + name: "safeTransferFrom", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "from", type: "address" }, + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "tokenId", type: "uint256" }, + { internalType: "bytes", name: "data", type: "bytes" }, + ], + name: "safeTransferFrom", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "operator", type: "address" }, + { internalType: "bool", name: "_approved", type: "bool" }, + ], + name: "setApprovalForAll", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "bytes4", name: "interfaceId", type: "bytes4" }], + name: "supportsInterface", + outputs: [{ internalType: "bool", name: "", type: "bool" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "symbol", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "index", type: "uint256" }], + name: "tokenByIndex", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "owner", type: "address" }, + { internalType: "uint256", name: "index", type: "uint256" }, + ], + name: "tokenOfOwnerByIndex", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [{ internalType: "uint256", name: "id", type: "uint256" }], + name: "tokenURI", + outputs: [{ internalType: "string", name: "", type: "string" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [], + name: "totalSupply", + outputs: [{ internalType: "uint256", name: "", type: "uint256" }], + stateMutability: "view", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "from", type: "address" }, + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "tokenId", type: "uint256" }, + ], + name: "transferFrom", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "address", name: "newOwner", type: "address" }], + name: "transferOwnership", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [{ internalType: "string", name: "sbtLink", type: "string" }], + name: "updateSBTFigure", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, + { + inputs: [ + { internalType: "address", name: "zkMazeContract", type: "address" }, + ], + name: "updatezkMazeContract", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +]; diff --git a/src/pages/game/_components/GameOver.tsx b/src/pages/game/_components/GameOver.tsx index 4fc9665..25ecc56 100644 --- a/src/pages/game/_components/GameOver.tsx +++ b/src/pages/game/_components/GameOver.tsx @@ -9,7 +9,7 @@ import { } from "wagmi"; import { useState, useEffect, useRef } from "react"; import FileSaver from "file-saver"; -import { generatePublicInput, gameState } from "../_utils"; +import { generatePublicInput, gameState, useEvmSbt } from "../_utils"; import { PROGRAM_STRING, ABI, @@ -18,7 +18,11 @@ import { idlFactory, FaucetMap, } from "@/constants"; -import { useCurrentChain, useEVMContractAddress } from "../_utils"; +import { + useCurrentChain, + useEVMContractAddress, + useEVMSBTBrowser, +} from "../_utils"; import * as myWorker from "../_utils/zkpWorker.ts"; import { useWriteContract } from "wagmi"; import { useStateStore } from "@/store"; @@ -34,6 +38,7 @@ import { Transaction, } from "@solana/web3.js"; import useSolana from "../_utils/useSolana.ts"; +import SBTIcon from "@/assets/coin-svgrepo-com.svg?react"; const VerifyPayloadSchema: borsh.Schema = { struct: { @@ -105,6 +110,22 @@ export const GameOver = ({ useSolana(); const [solanaContractSuccess, setSolanaContractSuccess] = useState(false); + // SBT + const [mintHash, setMintHash] = useState(); + const { hasSBT, mint, mintLoading, refetchSBT } = useEvmSbt(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + const SBTBrowser = useEVMSBTBrowser(Number(hasSBT), mintHash); + const mintSBTResult = useWaitForTransactionReceipt({ + hash: mintHash as `0x${string}`, + }); + + useEffect(() => { + if (network !== "solana") { + console.log("refetchSBT", hasSBT); + void refetchSBT(); + } + }, [mintSBTResult, mintHash, refetchSBT, hasSBT, network]); + useEffect(() => { if (network !== "solana" && contractResult?.status === "success") { console.log("onRefresh contractResult", contractResult); @@ -515,6 +536,10 @@ export const GameOver = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [step]); + useEffect(() => { + console.log("hasSBT=", hasSBT); + }, [hasSBT]); + return ( @@ -584,6 +609,30 @@ export const GameOver = ({ [Browse Transaction] )} + {SettlementOver && + gameResult === 1 && + (hasSBT ? ( + SBTBrowser && window.open(SBTBrowser)} + > + [My SBT] + + ) : ( + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + void mint().then((hash: any) => { + console.log("mint", hash); + setMintHash(hash); + }); + }} + > + [ {mintLoading ? "Minting" : "Mint SBT"}] + + ))} {(SettlementOver || errorMsg) && ( { } }, [network, Chain]); - console.log("EVM ContractAddress=", ContractAddress); const { data: contractData, // isPending: isContractLoading, @@ -91,7 +90,7 @@ const Header = forwardRef((_props, ref) => { const { data: solanaData, - // isPending: isSolanaLoading, + isPending: isSolanaLoading, isSuccess: isSolanaSuccess, refetch: fetchSolana, } = useSolana(); @@ -115,12 +114,14 @@ const Header = forwardRef((_props, ref) => { if (network !== "solana") { void refetchContract?.(); } else { - fetchSolana?.(); + if (!isSolanaLoading) { + void fetchSolana?.(); + } } }, }; }, - [network, refetchContract, fetchSolana] + [network, refetchContract, isSolanaLoading, fetchSolana] ); const data = network !== "solana" ? contractData : solanaData; diff --git a/src/pages/game/_components/NetworkSwitch.tsx b/src/pages/game/_components/NetworkSwitch.tsx index 7771f2c..f6f295a 100644 --- a/src/pages/game/_components/NetworkSwitch.tsx +++ b/src/pages/game/_components/NetworkSwitch.tsx @@ -4,13 +4,13 @@ import { useCurrentChain } from "../_utils"; export const NetworkSwitch = () => { const { open } = useWeb3Modal(); const chain = useCurrentChain(); - console.log(chain); + return ( <> {chain?.name ? ( open({ view: "Networks" })} + onClick={() => void open({ view: "Networks" })} > { return ContractAddressMap[selectedNetworkId as unknown as number]; }; +export const useEVMSBTAddress = () => { + const { selectedNetworkId } = useWeb3ModalState(); + return SBTAddressMap[selectedNetworkId as unknown as number]; +}; + +export const useEVMSBTBrowser = (id?: number, hash?: string) => { + const { selectedNetworkId } = useWeb3ModalState(); + if (!id && !hash) { + return null; + } + return SBTBrowserMap[selectedNetworkId as unknown as number] + ? SBTBrowserMap[selectedNetworkId as unknown as number](id, hash) + : null; +}; + // 检测坐标系中的点是否越界 export function isOutOfBound(Map: TextureType[][], point: Step) { const { x, y } = point; diff --git a/src/pages/game/_utils/index.ts b/src/pages/game/_utils/index.ts index 3016964..2f8dc2f 100644 --- a/src/pages/game/_utils/index.ts +++ b/src/pages/game/_utils/index.ts @@ -1,3 +1,4 @@ export * from "./helper"; export * from "./config"; export * from "./gameState"; +export * from "./useEvmSBT"; diff --git a/src/pages/game/_utils/useEvmSBT.ts b/src/pages/game/_utils/useEvmSBT.ts new file mode 100644 index 0000000..1b5d187 --- /dev/null +++ b/src/pages/game/_utils/useEvmSBT.ts @@ -0,0 +1,49 @@ +import { useWriteContract, useReadContract, useAccount } from "wagmi"; + +import { useEVMSBTAddress, useCurrentChain } from "."; +import { useStateStore } from "@/store"; +import { SBTABI } from "@/constants"; + +import { useState } from "react"; +export function useEvmSbt() { + const Chain = useCurrentChain(); + const ContractAddress = useEVMSBTAddress(); + const { network } = useStateStore(); + + const { writeContractAsync } = useWriteContract(); + const { address } = useAccount(); + + const [mintLoading, setClaimLoading] = useState(false); + + const mint = () => { + setClaimLoading(true); + return writeContractAsync({ + address: ContractAddress, + abi: SBTABI, + functionName: "mint", + chainId: Chain?.id, + args: [address], + }).finally(() => setClaimLoading(false)); + }; + + const { + data: hasSBT, + refetch: refetchSBT, + // isPending: isContractLoading, + } = useReadContract({ + address: ContractAddress, + abi: SBTABI, + functionName: "checkTokenID", + args: [address], + query: { + enabled: network !== "solana", + }, + }); + + return { + hasSBT, + refetchSBT, + mint, + mintLoading, + }; +} diff --git a/src/pages/game/_utils/useSolana.ts b/src/pages/game/_utils/useSolana.ts index a2412b6..22fe21d 100644 --- a/src/pages/game/_utils/useSolana.ts +++ b/src/pages/game/_utils/useSolana.ts @@ -10,6 +10,7 @@ export default function useSolana() { ); const { network } = useStateStore(); + // eslint-disable-next-line react-hooks/exhaustive-deps const PROGRAM_ID = new PublicKey( "EfMghMxfMJUBh51G3u4JJGB2v1wFCHYCsBFo8Lz8QhJW" ); @@ -23,7 +24,7 @@ export default function useSolana() { } ); } - }, [publicKey]); + }, [PROGRAM_ID, publicKey]); const [isPending, setIsPending] = useState(false); const [isSuccess, setSuccess] = useState(false); @@ -62,11 +63,11 @@ export default function useSolana() { setSuccess(false); console.warn("No verifying account"); } - }, [verifyingAccount]); + }, [connection, isPending, network, verifyingAccount]); useEffect(() => { verifyingAccount && connection && refetch(); - }, [connection, verifyingAccount]); + }, [connection, refetch, verifyingAccount]); return { publicKey,