diff --git a/contracts/ContributionAccountingToken.sol b/contracts/ContributionAccountingToken.sol index 0704e0ae..ca1986cb 100644 --- a/contracts/ContributionAccountingToken.sol +++ b/contracts/ContributionAccountingToken.sol @@ -42,25 +42,36 @@ contract ContributionAccountingToken is ERC20, ERC20Permit, AccessControl { lastMintTimestamp = block.timestamp; } - function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) { + function maxMintableAmount() public view returns (uint256) { uint256 currentSupply = totalSupply(); + // If current supply is less than threshold, return remaining amount to threshold + if (currentSupply < thresholdSupply) { + return thresholdSupply - currentSupply; + } + + // Calculate based on expansion rate + uint256 elapsedTime = block.timestamp - lastMintTimestamp; + uint256 maxMintableAmount = (currentSupply * maxExpansionRate * elapsedTime) / (365 days * 100); + + // Also check against remaining max supply + uint256 remainingSupply = maxSupply - currentSupply; + + return maxMintableAmount < remainingSupply ? maxMintableAmount : remainingSupply; + } + + function mint(address to, uint256 amount) public onlyRole(MINTER_ROLE) { + // Minting fee calculation uint256 feeAmount = (amount * clowderFee) / denominator; + // Check against max mintable amount + require(amount + feeAmount <= maxMintableAmount(), "Exceeds maximum mintable amount"); + // Perform the actual minting _mint(to, amount); _mint(clowderTreasury, feeAmount); lastMintTimestamp = block.timestamp; - - // Require statements moved after fee calculation and minting - require(currentSupply + amount + feeAmount <= maxSupply, "Exceeds maximum supply"); - - if (currentSupply >= thresholdSupply) { - uint256 elapsedTime = block.timestamp - lastMintTimestamp; - uint256 maxMintableAmount = (currentSupply * maxExpansionRate * elapsedTime) / (365 days * 100); - require(amount + feeAmount <= maxMintableAmount, "Exceeds maximum expansion rate"); - } } function reduceMaxSupply(uint256 newMaxSupply) public onlyRole(DEFAULT_ADMIN_ROLE) { diff --git a/package-lock.json b/package-lock.json index a05ecdf5..d9ab9c8a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,7 +6,9 @@ "": { "name": "hardhat-project", "dependencies": { - "@openzeppelin/contracts": "^5.1.0" + "@openzeppelin/contracts": "^5.1.0", + "@radix-ui/react-separator": "^1.1.7", + "sonner": "^2.0.5" }, "devDependencies": { "@nomicfoundation/hardhat-toolbox": "^5.0.0", @@ -1514,6 +1516,81 @@ "integrity": "sha512-p1ULhl7BXzjjbha5aqst+QMLY+4/LCWADXOCsmLHRM77AqiPjnd9vvUN9sosUfhL9JGKpZ0TjEGxgvnizmWGSA==", "license": "MIT" }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@scure/base": { "version": "1.1.9", "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.9.tgz", @@ -5891,6 +5968,27 @@ "node": ">= 0.8" } }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "peer": true, + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, "node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -6292,6 +6390,12 @@ "node": ">=0.8.0" } }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "peer": true + }, "node_modules/scrypt-js": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/scrypt-js/-/scrypt-js-3.0.1.tgz", @@ -6806,6 +6910,15 @@ "node": ">= 4.0.0" } }, + "node_modules/sonner": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.5.tgz", + "integrity": "sha512-YwbHQO6cSso3HBXlbCkgrgzDNIhws14r4MO87Ofy+cV2X7ES4pOoAK3+veSmVTvqNx1BWUxlhPmZzP00Crk2aQ==", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", diff --git a/package.json b/package.json index 5b9e6314..db8e6c82 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "hardhat": "^2.22.17" }, "dependencies": { - "@openzeppelin/contracts": "^5.1.0" + "@openzeppelin/contracts": "^5.1.0", + "@radix-ui/react-separator": "^1.1.7", + "sonner": "^2.0.5" } } diff --git a/web/src/app/[cat]/InteractionClient.tsx b/web/src/app/[cat]/InteractionClient.tsx index bf8eca15..c57b4575 100644 --- a/web/src/app/[cat]/InteractionClient.tsx +++ b/web/src/app/[cat]/InteractionClient.tsx @@ -1,8 +1,8 @@ "use client"; import React, { useEffect, useState, useCallback } from "react"; -import { Info, Coins, Settings, ArrowUpRight, ArrowDownRight, Unlock } from "lucide-react"; -import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; +import { Info, Coins, Settings, Unlock, Copy, ArrowUp, Target } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; import { getPublicClient } from "@wagmi/core"; import { config } from "@/utils/config"; import { useSearchParams } from "next/navigation"; @@ -10,12 +10,15 @@ import { CONTRIBUTION_ACCOUNTING_TOKEN_ABI } from "@/contractsABI/ContributionAc import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { useAccount, useWriteContract, useWaitForTransactionReceipt } from "wagmi"; +import { useWriteContract, useWaitForTransactionReceipt } from "wagmi"; import { parseEther } from "viem"; import { showTransactionToast } from "@/components/ui/transaction-toast"; import { motion } from "framer-motion"; import { LoadingState } from "@/components/ui/loading-state"; import { ButtonLoadingState } from "@/components/ui/button-loading-state"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { toast } from "sonner"; +import { catExplorer } from "@/utils/catExplorer"; // Define supported chain IDs type SupportedChainId = 1 | 137 | 534351 | 5115 | 61 | 2001; @@ -26,12 +29,15 @@ interface TokenDetailsState { maxSupply: number; thresholdSupply: number; maxExpansionRate: number; + currentSupply: number; transactionHash: string; + tokenAddress: string; timestamp: string; + lastMintTimestamp: number; + maxMintableAmount: number; } export default function InteractionClient() { - const { address } = useAccount(); const searchParams = useSearchParams(); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -40,7 +46,7 @@ export default function InteractionClient() { const [newThresholdSupply, setNewThresholdSupply] = useState(""); const [newMaxExpansionRate, setNewMaxExpansionRate] = useState(""); const [transferRestricted, setTransferRestricted] = useState(true); - + const [mintToAddress, setMintToAddress] = useState(""); const [tokenAddress, setTokenAddress] = useState<`0x${string}`>("0x0"); const [chainId, setChainId] = useState(null); @@ -51,13 +57,29 @@ export default function InteractionClient() { maxSupply: 0, thresholdSupply: 0, maxExpansionRate: 0, + currentSupply: 0, transactionHash: "", + tokenAddress: "", timestamp: "", + lastMintTimestamp: 0, + maxMintableAmount: 0, }); // Add new state for transaction signing const [isSigning, setIsSigning] = useState(false); + const [minterAddress, setMinterAddress] = useState(""); + const { writeContract: grantMinterRole, data: grantMinterRoleData } = useWriteContract(); + const { writeContract: revokeMinterRole, data: revokeMinterRoleData } = useWriteContract(); + + const { isLoading: isGrantingMinterRole } = useWaitForTransactionReceipt({ + hash: grantMinterRoleData, + }); + + const { isLoading: isRevokingMinterRole } = useWaitForTransactionReceipt({ + hash: revokeMinterRoleData, + }); + // Type guard for chain ID validation const isValidChainId = useCallback((chainId: number): chainId is SupportedChainId => { const validChainIds: SupportedChainId[] = [1, 137, 534351, 5115, 61, 2001]; @@ -109,7 +131,7 @@ export default function InteractionClient() { throw new Error(`No public client available for chain ${chainId}`); } - const [name, symbol, maxSupply, threshold, expansionRate] = + const [name, symbol, maxSupply, threshold, expansionRate, currentSupply, lastMint, maxMintable] = (await Promise.all([ publicClient.readContract({ address: tokenAddress, @@ -136,7 +158,22 @@ export default function InteractionClient() { abi: CONTRIBUTION_ACCOUNTING_TOKEN_ABI, functionName: "maxExpansionRate", }), - ])) as [string, string, bigint, bigint, bigint]; + publicClient.readContract({ + address: tokenAddress, + abi: CONTRIBUTION_ACCOUNTING_TOKEN_ABI, + functionName: "totalSupply", + }), + publicClient.readContract({ + address: tokenAddress, + abi: CONTRIBUTION_ACCOUNTING_TOKEN_ABI, + functionName: "lastMintTimestamp", + }), + publicClient.readContract({ + address: tokenAddress, + abi: CONTRIBUTION_ACCOUNTING_TOKEN_ABI, + functionName: "maxMintableAmount", + }), + ])) as [string, string, bigint, bigint, bigint, bigint, bigint, bigint]; if (!name || !symbol) { throw new Error("Invalid token contract"); @@ -148,8 +185,12 @@ export default function InteractionClient() { maxSupply: Number(maxSupply) / 10 ** 18, thresholdSupply: Number(threshold) / 10 ** 18, maxExpansionRate: Number(expansionRate) / 100, + currentSupply: Number(currentSupply) / 10 ** 18, transactionHash: tokenAddress, + tokenAddress: tokenAddress, timestamp: new Date().toISOString(), + lastMintTimestamp: Number(lastMint), + maxMintableAmount: Number(maxMintable) / 10 ** 18, }); const restricted = (await publicClient.readContract({ @@ -170,6 +211,11 @@ export default function InteractionClient() { useEffect(() => { if (tokenAddress && chainId) { getTokenDetails(); + // Set the token address in the details + setTokenDetails(prev => ({ + ...prev, + tokenAddress: tokenAddress + })); } }, [tokenAddress, chainId, getTokenDetails]); @@ -208,8 +254,11 @@ export default function InteractionClient() { chainId: chainId!, message: "Tokens minted successfully!", }); + // Refresh token details to get updated lastMintTimestamp and supply + getTokenDetails(); + setIsSigning(false); } - }, [mintData, chainId]); + }, [mintData, chainId, getTokenDetails]); useEffect(() => { if (reduceMaxSupplyData) { @@ -251,7 +300,7 @@ export default function InteractionClient() { } }, [disableTransferRestrictionData, chainId]); - // Update the mint function + // Update the mint function to only show toast after transaction const handleMint = async () => { try { setIsSigning(true); @@ -259,7 +308,7 @@ export default function InteractionClient() { abi: CONTRIBUTION_ACCOUNTING_TOKEN_ABI, address: tokenAddress, functionName: "mint", - args: [address, parseEther(mintAmount)] + args: [mintToAddress as `0x${string}`, parseEther(mintAmount)] }); } catch (error) { console.error("Error minting tokens:", error); @@ -269,7 +318,6 @@ export default function InteractionClient() { success: false, message: "Failed to mint tokens", }); - } finally { setIsSigning(false); } }; @@ -365,6 +413,98 @@ export default function InteractionClient() { } }; + const handleGrantMinterRole = async () => { + const confirmed = window.confirm( + `Are you sure you want to grant minter role to ${minterAddress}?` + ); + if (!confirmed) return; + + try { + setIsSigning(true); + await grantMinterRole({ + abi: CONTRIBUTION_ACCOUNTING_TOKEN_ABI, + address: tokenAddress, + functionName: "grantMinterRole", + args: [minterAddress as `0x${string}`] + }); + } catch (error) { + console.error("Error granting minter role:", error); + showTransactionToast({ + hash: "0x0" as `0x${string}`, + chainId: chainId!, + success: false, + message: "Failed to grant minter role", + }); + } finally { + setIsSigning(false); + } + }; + + const handleRevokeMinterRole = async () => { + const confirmed = window.confirm( + `Are you sure you want to revoke minter role from ${minterAddress}?` + ); + if (!confirmed) return; + + try { + setIsSigning(true); + await revokeMinterRole({ + abi: CONTRIBUTION_ACCOUNTING_TOKEN_ABI, + address: tokenAddress, + functionName: "revokeMinterRole", + args: [minterAddress as `0x${string}`] + }); + } catch (error) { + console.error("Error revoking minter role:", error); + showTransactionToast({ + hash: "0x0" as `0x${string}`, + chainId: chainId!, + success: false, + message: "Failed to revoke minter role", + }); + } finally { + setIsSigning(false); + } + }; + + useEffect(() => { + if (grantMinterRoleData) { + showTransactionToast({ + hash: grantMinterRoleData, + chainId: chainId!, + message: "Minter role granted successfully!", + }); + setMinterAddress(""); + } + }, [grantMinterRoleData, chainId]); + + useEffect(() => { + if (revokeMinterRoleData) { + showTransactionToast({ + hash: revokeMinterRoleData, + chainId: chainId!, + message: "Minter role revoked successfully!", + }); + setMinterAddress(""); + } + }, [revokeMinterRoleData, chainId]); + + const handleCopyAddress = () => { + navigator.clipboard.writeText(tokenAddress); + toast.success("Address copied to clipboard!"); + }; + + // Add a polling effect to keep max mintable amount up to date + useEffect(() => { + if (tokenDetails.currentSupply >= tokenDetails.thresholdSupply) { + const interval = setInterval(() => { + getTokenDetails(); + }, 30000); // Update every 30 seconds + + return () => clearInterval(interval); + } + }, [tokenDetails.currentSupply, tokenDetails.thresholdSupply, getTokenDetails]); + if (isLoading) { return ( -
+
{/* Header Section */} -
+
{tokenDetails.tokenSymbol} Token Management - - {tokenDetails.tokenName} -
- {/* Token Overview Card */} - - - - - Token Overview - - - -
-
-
- -

Max Supply

-
-

- {tokenDetails.maxSupply} -

-
-
-
- -

Threshold Supply

-
-

- {tokenDetails.thresholdSupply} -

-
-
-
- -

Max Expansion Rate

-
-

- {tokenDetails.maxExpansionRate}% -

-
-
-
-
- -

Contract Address

-
-

- {tokenDetails.transactionHash} -

-
-
-
- - {/* Mint Tokens Card */} - - - - - Mint Tokens - - - -
-
- - setMintAmount(e.target.value)} - className="h-12 text-lg bg-white/60 dark:bg-[#1a1400]/70 border-2 border-gray-200 dark:border-yellow-400/20 text-gray-600 dark:text-yellow-200" - /> -
- -
-
-
- - {/* Admin Functions Card */} + {/* Combined Token Overview and Admin Functions Card */} - - - - Admin Functions - - +
-
-
- - setNewMaxSupply(e.target.value)} - className="h-12 text-lg bg-white/60 dark:bg-[#1a1400]/70 border-2 border-gray-200 dark:border-yellow-400/20 text-gray-600 dark:text-yellow-200" - /> - + {/* Left Column - Token Stats */} +
+
+
+
+ +

Max Supply

+
+

{tokenDetails.maxSupply} {tokenDetails.tokenSymbol}

+
+ + + + + + + Reduce Max Supply +

+ Current max supply: {tokenDetails.maxSupply} {tokenDetails.tokenSymbol} +

+
+
+
+ setNewMaxSupply(e.target.value)} + className="h-10" + /> +

+ Must be less than current max supply +

+
+ +
+
+
-
- - setNewThresholdSupply(e.target.value)} - className="h-12 text-lg bg-white/60 dark:bg-[#1a1400]/70 border-2 border-gray-200 dark:border-yellow-400/20 text-gray-600 dark:text-yellow-200" - /> - +
+
+
+ +

Threshold Supply

+
+

{tokenDetails.thresholdSupply} {tokenDetails.tokenSymbol}

+
+ + + + + + + Reduce Threshold Supply +

+ Current threshold: {tokenDetails.thresholdSupply} {tokenDetails.tokenSymbol} +

+
+
+
+ setNewThresholdSupply(e.target.value)} + className="h-10" + /> +

+ Must be less than current threshold supply +

+
+ +
+
+
+
+ +
+
+
+ +

Expansion Rate

+
+

{tokenDetails.maxExpansionRate} %

+
+ + + + + + + Reduce Max Expansion Rate +

+ Current rate: {tokenDetails.maxExpansionRate}% +

+
+
+
+ setNewMaxExpansionRate(e.target.value)} + className="h-10" + /> +

+ Must be less than current expansion rate +

+
+ +
+
+
-
-
-
- - setNewMaxExpansionRate(e.target.value)} - className="h-12 text-lg bg-white/60 dark:bg-[#1a1400]/70 border-2 border-gray-200 dark:border-yellow-400/20 text-gray-600 dark:text-yellow-200" - /> - + ) : ( - "Update Max Expansion Rate" +

+ Transfers to any address are already enabled +

)} - +
+
- {transferRestricted ? ( -
- + {/* Right Column - Minting and Transfer Restriction */} +
+
+
+ +

Mint Tokens

+
+
+
+

Max Mintable Amount: {tokenDetails.maxMintableAmount} {tokenDetails.tokenSymbol}

+

Current Supply: {tokenDetails.currentSupply} {tokenDetails.tokenSymbol}

+
+
+ + setMintAmount(e.target.value)} + className="h-10 text-sm bg-white/60 dark:bg-[#2a1a00] border-2 border-gray-200 dark:border-yellow-400/20 text-gray-600 dark:text-yellow-200" + /> +
+
+ + setMintToAddress(e.target.value)} + className="h-10 text-sm bg-white/60 dark:bg-[#2a1a00] border-2 border-gray-200 dark:border-yellow-400/20 text-gray-600 dark:text-yellow-200" + /> +
+
+
- ) : ( -
- -

Transfer restriction is already disabled

+
+ +
+
+ +

Minter Role Management

- )} +
+
+ + setMinterAddress(e.target.value)} + className="h-10 text-sm bg-white/60 dark:bg-[#2a1a00] border-2 border-gray-200 dark:border-yellow-400/20 text-gray-600 dark:text-yellow-200" + /> +
+
+ + +
+
+
+ + {/* Contract Address at the bottom */} +
diff --git a/web/src/app/my-cats/page.tsx b/web/src/app/my-cats/page.tsx index 5bf8d2c0..d1c1be2e 100644 --- a/web/src/app/my-cats/page.tsx +++ b/web/src/app/my-cats/page.tsx @@ -275,18 +275,16 @@ export default function MyCATsPage() { >
-
-
- {cat.tokenSymbol.slice(0, 2)} -
-
-

- {cat.tokenName || cat.address} -

-

- {cat.tokenSymbol} -

-
+
+ {cat.tokenSymbol.slice(0, 2)} +
+
+

+ {cat.tokenName || cat.address} +

+

+ {cat.tokenSymbol} +

{ + const baseUrls: { [key: number]: string } = { + 1: "https://etherscan.io/address/", + 137: "https://polygonscan.com/address/", + 534351: "https://sepolia.scrollscan.com/address/", + 5115: "https://explorer.testnet.mantle.xyz/address/", + 61: "https://explorer.testnet.rsk.co/address/", + 2001: "https://explorer.testnet.milkomeda.com/address/", + }; + + const baseUrl = baseUrls[chainId]; + if (!baseUrl) { + throw new Error(`Unsupported chain ID: ${chainId}`); + } + + return `${baseUrl}${hash}`; + }; \ No newline at end of file