add PAD token

This commit is contained in:
osmannyildiz 2024-04-20 23:21:06 +03:00
parent 545bdf7bae
commit fec688478b
19 changed files with 976 additions and 80 deletions

View File

@ -1,5 +1,6 @@
rm "./frontend/src/evm-output/*" rm ./frontend/src/evm-output/*
cp "./evm/ignition/deployments/chain-31337/deployed_addresses.json" "./frontend/src/evm-output/deployed_addresses.json" cp "./evm/ignition/deployments/chain-31337/deployed_addresses.json" "./frontend/src/evm-output/deployed_addresses.json"
cp "./evm/ignition/deployments/chain-31337/artifacts/MessageBoxModule#MessageBox.json" "./frontend/src/evm-output/MessageBox.artifacts.json" cp "./evm/ignition/deployments/chain-31337/artifacts/MessageBoxModule#MessageBox.json" "./frontend/src/evm-output/MessageBox.artifacts.json"
cp "./evm/ignition/deployments/chain-31337/artifacts/MainModule#PADToken.json" "./frontend/src/evm-output/PADToken.artifacts.json"

2
evm/config.ts Normal file
View File

@ -0,0 +1,2 @@
export const PAD_TOKEN_INITIAL_OWNER =
"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";

View File

@ -0,0 +1,19 @@
// SPDX-License-Identifier: MIT
// Compatible with OpenZeppelin Contracts ^5.0.0
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";
contract PADToken is ERC20, Ownable, ERC20Permit {
constructor(address initialOwner)
ERC20("PAD Token", "PAD")
Ownable(initialOwner)
ERC20Permit("PAD Token")
{}
function mint(address to, uint256 amount) public onlyOwner {
_mint(to, amount);
}
}

View File

@ -0,0 +1,15 @@
import { buildModule } from "@nomicfoundation/hardhat-ignition/modules";
import { PAD_TOKEN_INITIAL_OWNER } from "../../config";
const MainModule = buildModule("MainModule", (m) => {
const initialOwner = m.getParameter(
"padTokenInitialOwner",
PAD_TOKEN_INITIAL_OWNER
);
const padToken = m.contract("PADToken", [initialOwner]);
return { padToken };
});
export default MainModule;

View File

@ -11,6 +11,7 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@openzeppelin/contracts": "^5.0.2",
"hardhat": "^2.22.2" "hardhat": "^2.22.2"
}, },
"devDependencies": { "devDependencies": {

View File

@ -5,6 +5,9 @@ settings:
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
dependencies: dependencies:
'@openzeppelin/contracts':
specifier: ^5.0.2
version: 5.0.2
hardhat: hardhat:
specifier: ^2.22.2 specifier: ^2.22.2
version: 2.22.2(ts-node@10.9.2)(typescript@5.4.5) version: 2.22.2(ts-node@10.9.2)(typescript@5.4.5)
@ -844,6 +847,10 @@ packages:
'@nomicfoundation/solidity-analyzer-win32-ia32-msvc': 0.1.1 '@nomicfoundation/solidity-analyzer-win32-ia32-msvc': 0.1.1
'@nomicfoundation/solidity-analyzer-win32-x64-msvc': 0.1.1 '@nomicfoundation/solidity-analyzer-win32-x64-msvc': 0.1.1
/@openzeppelin/contracts@5.0.2:
resolution: {integrity: sha512-ytPc6eLGcHHnapAZ9S+5qsdomhjo6QBHTDRRBFfTxXIpsicMhVPouPgmUPebZZZGX7vt9USA+Z+0M0dSVtSUEA==}
dev: false
/@scure/base@1.1.6: /@scure/base@1.1.6:
resolution: {integrity: sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==} resolution: {integrity: sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==}

View File

@ -1,18 +1,20 @@
module.exports = { module.exports = {
root: true, root: true,
env: { browser: true, es2020: true }, env: { browser: true, es2020: true },
extends: [ extends: [
'eslint:recommended', "eslint:recommended",
'plugin:@typescript-eslint/recommended', "plugin:@typescript-eslint/recommended",
'plugin:react-hooks/recommended', "plugin:react-hooks/recommended",
], ],
ignorePatterns: ['dist', '.eslintrc.cjs'], ignorePatterns: ["dist", ".eslintrc.cjs"],
parser: '@typescript-eslint/parser', parser: "@typescript-eslint/parser",
plugins: ['react-refresh'], plugins: ["react-refresh"],
rules: { rules: {
'react-refresh/only-export-components': [ "react-refresh/only-export-components": [
'warn', "warn",
{ allowConstantExport: true }, { allowConstantExport: true },
], ],
}, // My additions
} "@typescript-eslint/ban-ts-comment": "off",
},
};

View File

@ -14,7 +14,8 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"sonner": "^1.4.41", "sonner": "^1.4.41",
"web3": "^4.7.0" "web3": "^4.7.0",
"zustand": "^4.5.2"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/router-vite-plugin": "^1.28.2", "@tanstack/router-vite-plugin": "^1.28.2",

View File

@ -20,6 +20,9 @@ dependencies:
web3: web3:
specifier: ^4.7.0 specifier: ^4.7.0
version: 4.7.0(typescript@5.4.5) version: 4.7.0(typescript@5.4.5)
zustand:
specifier: ^4.5.2
version: 4.5.2(@types/react@18.2.79)(react@18.2.0)
devDependencies: devDependencies:
'@tanstack/router-vite-plugin': '@tanstack/router-vite-plugin':
@ -1026,7 +1029,6 @@ packages:
/@types/prop-types@15.7.12: /@types/prop-types@15.7.12:
resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==} resolution: {integrity: sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==}
dev: true
/@types/react-dom@18.2.25: /@types/react-dom@18.2.25:
resolution: {integrity: sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==} resolution: {integrity: sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==}
@ -1039,7 +1041,6 @@ packages:
dependencies: dependencies:
'@types/prop-types': 15.7.12 '@types/prop-types': 15.7.12
csstype: 3.1.3 csstype: 3.1.3
dev: true
/@types/semver@7.5.8: /@types/semver@7.5.8:
resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==}
@ -1484,7 +1485,6 @@ packages:
/csstype@3.1.3: /csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
dev: true
/culori@3.3.0: /culori@3.3.0:
resolution: {integrity: sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==} resolution: {integrity: sha512-pHJg+jbuFsCjz9iclQBqyL3B2HLCBF71BwVNujUYEvCeQMvV97R59MNK3R2+jgJ3a1fcZgI9B3vYgz8lzr/BFQ==}
@ -3233,3 +3233,23 @@ packages:
/zod@3.22.4: /zod@3.22.4:
resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==} resolution: {integrity: sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==}
/zustand@4.5.2(@types/react@18.2.79)(react@18.2.0):
resolution: {integrity: sha512-2cN1tPkDVkwCy5ickKrI7vijSjPksFRfqS6237NzT0vqSsztTNnQdHw9mmN7uBdk3gceVXU0a+21jFzFzAc9+g==}
engines: {node: '>=12.7.0'}
peerDependencies:
'@types/react': '>=16.8'
immer: '>=9.0.6'
react: '>=16.8'
peerDependenciesMeta:
'@types/react':
optional: true
immer:
optional: true
react:
optional: true
dependencies:
'@types/react': 18.2.79
react: 18.2.0
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false

View File

@ -0,0 +1,41 @@
import { useStore } from "../store";
import { shortenAddress, tryWithToast } from "../utils";
import { web3 } from "../web3";
export function ConnectMetamaskButton() {
const connectedAccount = useStore((state) => state.connectedAccount);
const setConnectedAccount = useStore((state) => state.setConnectedAccount);
const unsetConnectedAccount = useStore(
(state) => state.unsetConnectedAccount
);
// https://docs.web3js.org/guides/getting_started/metamask/#react-app
async function connectMetamask() {
await tryWithToast("Connect MetaMask", async () => {
// Request user to connect accounts (MetaMask will prompt)
await window.ethereum.request({ method: "eth_requestAccounts" });
// Get the connected accounts
const accounts = await web3.eth.getAccounts();
// Show the first connected account in the page
setConnectedAccount(accounts[0]);
});
}
function disconnectWallet() {
unsetConnectedAccount();
}
return (
<button
type="button"
className="btn"
onClick={() =>
connectedAccount ? disconnectWallet() : connectMetamask()
}
>
{connectedAccount ? shortenAddress(connectedAccount) : "Connect MetaMask"}
</button>
);
}

File diff suppressed because one or more lines are too long

View File

@ -1,3 +1,4 @@
{ {
"MessageBoxModule#MessageBox": "0x5FbDB2315678afecb367f032d93F642f64180aa3" "MessageBoxModule#MessageBox": "0x5FbDB2315678afecb367f032d93F642f64180aa3",
"MainModule#PADToken": "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9"
} }

View File

@ -16,12 +16,18 @@ import { Route as rootRoute } from './routes/__root'
// Create Virtual Routes // Create Virtual Routes
const PadTokenLazyImport = createFileRoute('/pad-token')()
const MessageBoxLazyImport = createFileRoute('/message-box')() const MessageBoxLazyImport = createFileRoute('/message-box')()
const AboutLazyImport = createFileRoute('/about')() const AboutLazyImport = createFileRoute('/about')()
const IndexLazyImport = createFileRoute('/')() const IndexLazyImport = createFileRoute('/')()
// Create/Update Routes // Create/Update Routes
const PadTokenLazyRoute = PadTokenLazyImport.update({
path: '/pad-token',
getParentRoute: () => rootRoute,
} as any).lazy(() => import('./routes/pad-token.lazy').then((d) => d.Route))
const MessageBoxLazyRoute = MessageBoxLazyImport.update({ const MessageBoxLazyRoute = MessageBoxLazyImport.update({
path: '/message-box', path: '/message-box',
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
@ -53,6 +59,10 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MessageBoxLazyImport preLoaderRoute: typeof MessageBoxLazyImport
parentRoute: typeof rootRoute parentRoute: typeof rootRoute
} }
'/pad-token': {
preLoaderRoute: typeof PadTokenLazyImport
parentRoute: typeof rootRoute
}
} }
} }
@ -62,6 +72,7 @@ export const routeTree = rootRoute.addChildren([
IndexLazyRoute, IndexLazyRoute,
AboutLazyRoute, AboutLazyRoute,
MessageBoxLazyRoute, MessageBoxLazyRoute,
PadTokenLazyRoute,
]) ])
/* prettier-ignore-end */ /* prettier-ignore-end */

View File

@ -1,5 +1,6 @@
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"; import { createRootRoute, Link, Outlet } from "@tanstack/react-router";
import { Toaster } from "sonner"; import { Toaster } from "sonner";
import { ConnectMetamaskButton } from "../components/ConnectMetamaskButton";
export const Route = createRootRoute({ export const Route = createRootRoute({
component: RootLayout, component: RootLayout,
@ -8,20 +9,26 @@ export const Route = createRootRoute({
function RootLayout() { function RootLayout() {
return ( return (
<> <>
<header className="bg-primary text-primary-content p-4"> <header className="bg-primary text-primary-content p-4 flex items-center">
<span className="text-4xl font-bold">OsiPad&trade;</span> <div className="flex-grow">
<hr className="my-1" /> <span className="text-4xl font-bold">OsiPad&trade;</span>
<div className="flex gap-2"> <hr className="my-1" />
<Link to="/" className="[&.active]:font-bold"> <div className="flex gap-3">
Home <Link to="/" className="[&.active]:font-bold">
</Link> Home
<Link to="/about" className="[&.active]:font-bold"> </Link>
About <Link to="/about" className="[&.active]:font-bold">
</Link> About
<Link to="/message-box" className="[&.active]:font-bold"> </Link>
Message Box <Link to="/message-box" className="[&.active]:font-bold">
</Link> Message Box
</Link>
<Link to="/pad-token" className="[&.active]:font-bold">
PAD Token
</Link>
</div>
</div> </div>
<ConnectMetamaskButton />
</header> </header>
<main> <main>
<Outlet /> <Outlet />

View File

@ -1,31 +1,18 @@
import { createLazyFileRoute } from "@tanstack/react-router"; import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react"; import { useState } from "react";
import { tryWithToast } from "../utils"; import { useStore } from "../store";
import { messageBox, web3 } from "../web3"; import { toastError, tryWithToast } from "../utils";
import { messageBox } from "../web3";
export const Route = createLazyFileRoute("/message-box")({ export const Route = createLazyFileRoute("/message-box")({
component: MessageBoxPage, component: MessageBoxPage,
}); });
function MessageBoxPage() { function MessageBoxPage() {
const [connectedAccount, setConnectedAccount] = useState("(nothingness)"); const connectedAccount = useStore((state) => state.connectedAccount);
const [message, setMessage] = useState("(nothingness)"); const [message, setMessage] = useState("(nothingness)");
const [newMessage, setNewMessage] = useState(""); const [newMessage, setNewMessage] = useState("");
// https://docs.web3js.org/guides/getting_started/metamask/#react-app
async function connectMetamask() {
await tryWithToast("Connect MetaMask", async () => {
// Request user to connect accounts (MetaMask will prompt)
await window.ethereum.request({ method: "eth_requestAccounts" });
// Get the connected accounts
const accounts = await web3.eth.getAccounts();
// Show the first connected account in the page
setConnectedAccount(accounts[0]);
});
}
async function getMessage() { async function getMessage() {
await tryWithToast("Get Message", async () => { await tryWithToast("Get Message", async () => {
const _message: string = await messageBox.methods.getMessage().call(); const _message: string = await messageBox.methods.getMessage().call();
@ -34,6 +21,11 @@ function MessageBoxPage() {
} }
async function _setMessage() { async function _setMessage() {
if (!connectedAccount) {
toastError("Set Message", "Connect your wallet first.");
return;
}
await tryWithToast("Set Message", async () => { await tryWithToast("Set Message", async () => {
await messageBox.methods await messageBox.methods
.setMessage(newMessage) .setMessage(newMessage)
@ -43,24 +35,6 @@ function MessageBoxPage() {
return ( return (
<div className="container mx-auto p-4 grid md:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="container mx-auto p-4 grid md:grid-cols-2 xl:grid-cols-3 gap-4">
<div className="card bg-base-200">
<div className="card-body items-start">
<h2 className="text-2xl font-bold">Essentials</h2>
<button
type="button"
className="btn btn-primary"
onClick={() => connectMetamask()}
>
Connect to MetaMask
</button>
<div>
<span className="font-bold">Connected account address:</span>{" "}
{connectedAccount}
</div>
</div>
</div>
<div className="card bg-base-200"> <div className="card bg-base-200">
<div className="card-body items-start"> <div className="card-body items-start">
<h2 className="text-2xl font-bold">Get Message</h2> <h2 className="text-2xl font-bold">Get Message</h2>

View File

@ -0,0 +1,123 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import { useState } from "react";
import type { Numbers } from "web3";
import { useStore } from "../store";
import { getFormInputValue, toastError, tryWithToast } from "../utils";
import { padToken, web3 } from "../web3";
export const Route = createLazyFileRoute("/pad-token")({
component: PadTokenPage,
});
function PadTokenPage() {
const connectedAccount = useStore((state) => state.connectedAccount);
const [balance, setBalance] = useState("(nothingness)");
const onBalanceOfFormSubmit: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault();
const address = getFormInputValue(
event.target as HTMLFormElement,
"address"
).trim();
if (!address) {
toastError("Balance Of", "Enter an address.");
return;
}
await tryWithToast("Balance Of", async () => {
const balanceBN: Numbers = await padToken.methods
.balanceOf(address)
.call();
setBalance(web3.utils.fromWei(balanceBN, "ether"));
});
};
const onMintFormSubmit: React.FormEventHandler<HTMLFormElement> = async (
event
) => {
event.preventDefault();
if (!connectedAccount) {
toastError("Mint", "Connect your wallet first.");
return;
}
const to = getFormInputValue(event.target as HTMLFormElement, "to").trim();
if (!to) {
toastError("Mint", "Enter a target address.");
return;
}
const amount = getFormInputValue(
event.target as HTMLFormElement,
"amount"
).trim();
if (!amount) {
toastError("Mint", "Enter an amount.");
return;
}
await tryWithToast("Mint", async () => {
await padToken.methods
.mint(to, web3.utils.toWei(amount, "ether"))
.send({ from: connectedAccount });
});
};
return (
<div className="container mx-auto p-4 grid md:grid-cols-2 xl:grid-cols-3 gap-4">
<div className="card bg-base-200">
<div className="card-body items-start">
<h2 className="text-2xl font-bold">Balance Of</h2>
<form onSubmit={onBalanceOfFormSubmit}>
<input
type="text"
name="address"
className="input"
placeholder="Address"
required
minLength={42}
maxLength={42}
/>
<button type="submit" className="btn btn-primary">
Get
</button>
<div>
<span className="font-bold">Balance:</span> {balance} PAD
</div>
</form>
</div>
</div>
<div className="card bg-base-200">
<div className="card-body items-start">
<h2 className="text-2xl font-bold">Mint</h2>
<form onSubmit={onMintFormSubmit}>
<input
type="text"
name="to"
className="input"
placeholder="To"
required
minLength={42}
maxLength={42}
/>
<input
type="number"
name="amount"
className="input"
placeholder="Amount"
required
/>
<button type="submit" className="btn btn-primary">
Mint
</button>
</form>
</div>
</div>
</div>
);
}

13
frontend/src/store.tsx Normal file
View File

@ -0,0 +1,13 @@
import { create } from "zustand";
interface State {
connectedAccount: string | null;
setConnectedAccount: (newAccount: string) => void;
unsetConnectedAccount: () => void;
}
export const useStore = create<State>()((set) => ({
connectedAccount: null,
setConnectedAccount: (newAccount) => set({ connectedAccount: newAccount }),
unsetConnectedAccount: () => set({ connectedAccount: null }),
}));

View File

@ -1,21 +1,41 @@
import { toast } from "sonner"; import { toast } from "sonner";
export function toastSuccess(taskName: string) {
toast.success(`${taskName}: Success`, {
className: "border-none bg-green-100",
});
}
export function toastError(taskName: string, errorMessage: string) {
toast.error(`${taskName}: Error`, {
description: errorMessage,
className: "border-none bg-red-100",
});
}
export async function tryWithToast( export async function tryWithToast(
taskName: string, taskName: string,
taskFn: () => Promise<void> taskFn: () => Promise<void>
) { ) {
try { try {
await taskFn(); await taskFn();
toast.success(`${taskName}: Success`, { toastSuccess(taskName);
className: "border-none bg-green-100",
});
} catch (error: any) { } catch (error: any) {
console.error(error); console.error(error);
toast.error(`${taskName}: Error`, { toastError(
description: taskName,
error.message || error.message ||
"No error message. Check the DevTools console for details.", "No error message. Check the DevTools console for details."
className: "border-none bg-red-100", );
});
} }
} }
export function shortenAddress(address: string) {
return address.slice(0, 6) + "..." + address.slice(-4);
}
export function getFormInputValue(form: HTMLFormElement, name: string) {
// @ts-expect-error
const input = form.elements[name] as HTMLInputElement;
return input.value;
}

View File

@ -1,5 +1,6 @@
import { Web3 } from "web3"; import { Web3 } from "web3";
import messageBoxArtifacts from "./evm-output/MessageBox.artifacts.json"; import messageBoxArtifacts from "./evm-output/MessageBox.artifacts.json";
import padTokenArtifacts from "./evm-output/PADToken.artifacts.json";
import deployedAddresses from "./evm-output/deployed_addresses.json"; import deployedAddresses from "./evm-output/deployed_addresses.json";
export let web3: Web3; export let web3: Web3;
@ -17,3 +18,8 @@ export const messageBox = new web3!.eth.Contract(
messageBoxArtifacts.abi, messageBoxArtifacts.abi,
deployedAddresses["MessageBoxModule#MessageBox"] deployedAddresses["MessageBoxModule#MessageBox"]
); );
export const padToken = new web3!.eth.Contract(
padTokenArtifacts.abi,
deployedAddresses["MainModule#PADToken"]
);