OH Week3 & Week4 Overview
NFT Website Development
「在 NFT 商品網站中與以太坊錢包 - MetaMask 連動,並且認證錢包登入者是否持有我們發行的 NFT。後讓登入者進入 NFT holders 的專屬 VIP 區域進行互動。」以及「產品上鏈工程 & 實作 Minting dAPP」
login=>start: Login Flow (Login System)
verify=>operation: Tokens Owner Check Flow (Verify System)
produce=>operation: Produce NFT Flow
deploy=>operation: Deploy NFT Flow
dApp=>end: Minting dAPP Flow
login->verify->produce->deploy->dApp
Login Flow (Login System) (react.js, MetaMask@onboarding, web3.js)
Tokens Owner Check Flow (Verify System) (react.js, web3.js, ethers.js, opensea.js)
Produce NFT Flow (python, JavaScript)
Deploy NFT Flow (Solidity, Etherscan)
Minting dAPP Flow (react.js, web3.js, ethers.js, Solidity)
| Login System |
【錢包登入區的前端建置】
Create a react app
目前的模板:
import * as React from 'react';
import AppBar from '@mui/material/AppBar';
import Button from '@mui/material/Button';
import ViewInArTwoToneIcon from '@mui/icons-material/ViewInArTwoTone';
import Card from '@mui/material/Card';
import CardActions from '@mui/material/CardActions';
import CardContent from '@mui/material/CardContent';
import CardMedia from '@mui/material/CardMedia';
import CssBaseline from '@mui/material/CssBaseline';
import Grid from '@mui/material/Grid';
import Stack from '@mui/material/Stack';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import Container from '@mui/material/Container';
import Link from '@mui/material/Link';
import { createTheme, ThemeProvider } from '@mui/material/styles';
function Copyright() {
return (
<Typography variant="body2" color="text.secondary" align="center">
{'Copyright © '}
<Link color="inherit" href="https://github.com/ChiHaoLu">
ChiHaoLu
</Link>{' '}
{new Date().getFullYear()}
{'.'}
</Typography>
);
}
const cards = [1, 2, 3];
const theme = createTheme({
palette: {
primary: {
light: '#757ce8',
main: '#fb8c00',
dark: '#002884',
contrastText: '#fff',
},
secondary: {
light: '#ff7961',
main: '#3d5afe',
dark: '#ba000d',
contrastText: '#000',
},
},
});
export default function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<AppBar position="relative">
<Toolbar>
<ViewInArTwoToneIcon sx={{ mr: 2 }} />
<Typography variant="h6" color="inherit" noWrap>
KilliFish
</Typography>
</Toolbar>
</AppBar>
<main>
{/* Hero unit */}
<Box
sx={{
bgcolor: 'background.paper',
pt: 8,
pb: 6,
}}
>
<Container maxWidth="sm">
<Typography
component="h1"
variant="h2"
align="center"
color="text.primary"
gutterBottom
>
KilliFish
</Typography>
<Typography variant="h5" align="center" color="text.secondary" paragraph>
The best courses platform for learning web3!
</Typography>
<Stack
sx={{ pt: 4 }}
direction="row"
spacing={2}
justifyContent="center"
>
<Button variant="contained">Login By MataMask</Button>
<Button variant="outlined">Verify Your Certificate</Button>
</Stack>
</Container>
</Box>
<Container sx={{ py: 8 }} maxWidth="md">
{/* End hero unit */}
<Grid container spacing={4}>
{cards.map((card) => (
<Grid item key={card} xs={12} sm={6} md={4}>
<Card
sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}
>
<CardMedia
component="img"
sx={{
// 16:9
pt: '56.25%',
}}
image="https://source.unsplash.com/random"
alt="random"
/>
<CardContent sx={{ flexGrow: 1 }}>
<Typography gutterBottom variant="h5" component="h2">
Courses
</Typography>
<Typography>
Join this Courses Free!
</Typography>
</CardContent>
<CardActions>
<Button size="small">View</Button>
<Button size="small">Enroll</Button>
</CardActions>
</Card>
</Grid>
))}
</Grid>
</Container>
</main>
{/* Footer */}
<Box sx={{ bgcolor: 'background.paper', p: 6 }} component="footer">
<Typography variant="h6" align="center" gutterBottom>
killifish.eth
</Typography>
<Typography
variant="subtitle1"
align="center"
color="text.secondary"
component="p"
>
The best courses platform for learning web3!
</Typography>
<Copyright />
</Box>
{/* End footer */}
</ThemeProvider>
);
}
【連動 Metamask】
| Verify System |
import * as React from 'react';
import { styled } from '@mui/material/styles';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import CardContent from '@mui/material/CardContent';
import CardActions from '@mui/material/CardActions';
import Collapse from '@mui/material/Collapse';
import Avatar from '@mui/material/Avatar';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import SchoolIcon from '@mui/icons-material/School';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import { useState } from "react";
import Container from '@mui/material/Container';
import FactCheckIcon from '@mui/icons-material/FactCheck';
const ExpandMore = styled((props) => {
const { expand, ...other } = props;
return <IconButton {...other} />;
})(({ theme, expand }) => ({
transform: !expand ? 'rotate(0deg)' : 'rotate(180deg)',
marginLeft: 'auto',
transition: theme.transitions.create('transform', {
duration: theme.transitions.duration.shortest,
}),
}));
export function MyProfile() {
const [expanded, setExpanded] = useState(false);
const handleExpandClick = () => {
setExpanded(!expanded);
};
return (
<Container sx={{ py: 6 }} maxWidth="md">
<Card sx={{ maxWidth: 555 }}>
<CardHeader
avatar={
<Avatar sx={{ bgcolor: "#A63446" }} aria-label="recipe">
0x
</Avatar>
}
title={accounts}
/>
<CardContent>
<Typography variant="body2" color="text.secondary">
兔美。<br /> 小學生,是一隻兔子,有名偵探的美稱,在有靈感(分析出「重要」案情時)的時候目光會變得銳利。
</Typography>
</CardContent>
<CardActions disableSpacing>
<IconButton href="/" aria-label="home" color="secondary">
<SchoolIcon />
</IconButton>
<IconButton href="/verify" aria-label="check" color="secondary">
<FactCheckIcon />
</IconButton>
<ExpandMore
expand={expanded}
onClick={handleExpandClick}
aria-expanded={expanded}
aria-label="show more"
color="secondary"
>
<ExpandMoreIcon />
</ExpandMore>
</CardActions>
<Collapse in={expanded} timeout="auto" unmountOnExit>
<CardContent>
<Typography paragraph>《搞笑漫畫日和》</Typography>
<Typography paragraph>
《搞笑漫畫日和》(日語:ギャグマンガ日和)是日本漫畫家増田こうすけ創作的日本漫畫作品。於集英社漫畫雜誌《月刊少年JUMP》2000年1月號開始連載。之後因為雜誌休刊的關係,改為《Jump Square》,連載至2015年12月號完結。單行本全15卷。現在改標題為《搞笑漫畫日和GB》(ギャグマンガ日和GB)於《Jump Square》上連載。
動畫共有四部,最新的一部是為紀念漫畫連載11周年於2010年1月開始播放的第4季動畫「搞笑漫畫日和+」。每集動畫的長度約為5分鐘。
</Typography>
</CardContent>
</Collapse>
</Card>
</Container>
);
}
【Smart Contract, and how do we get the token's owner?】
【Who is login now?】
import * as React from 'react';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import MenuItem from '@mui/material/MenuItem';
import InputLabel from '@mui/material/InputLabel';
import InputAdornment from '@mui/material/InputAdornment';
import FormControl from '@mui/material/FormControl';
import Input from '@mui/material/Input';
import Button from '@mui/material/Button';
import { useState } from "react";
import Alert from '@mui/material/Alert';
import IconButton from '@mui/material/IconButton';
import Collapse from '@mui/material/Collapse';
import CloseIcon from '@mui/icons-material/Close';
import * as web3 from 'web3'
import { OpenSeaPort, Network } from 'opensea-js'
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import { NFTE } from '@nfte/react';
const provider = new web3.providers.HttpProvider('https://mainnet.infura.io')
const seaport = new OpenSeaPort(provider, {
networkName: Network.Main
})
const ERCTypes = [
{
value: 'ERC20',
label: 'ERC-20 (Constructing)',
},
{
value: 'ERC721',
label: 'ERC-721 ',
},
{
value: 'ERC1155',
label: 'ERC-1155 (Constructing)',
},
];
const Classes = [
{
value: '0x67d9417c9c3c250f61a83c7e8658dac487b56b09',
label: 'DApp and Smart Contract Development (PHANTA BEAR)',
// PHANTA BEAR: https://etherscan.io/address/0x67d9417c9c3c250f61a83c7e8658dac487b56b09
},
{
value: 'smartcontract_address_2',
label: 'Enterprise-level Consortium Blockchain Development (Constructing)',
},
{
value: 'smartcontract_address_3',
label: 'Underlying Architecture of Blockchain (Constructing)',
},
];
export function MyVerify(props) {
const [erc, setERC] = useState('ERC721');
const [myclass, setClass] = useState('0x67d9417c9c3c250f61a83c7e8658dac487b56b09');
const [values, setValues] = useState({
id: ""
});
// tokenId: 7476 -- https://opensea.io/assets/0x67d9417c9c3c250f61a83c7e8658dac487b56b09/7476
const [open, setOpen] = useState(false);
const [account, setAccount] = useState("0x27b00e6109f246d9d42aaf4a12f0ae35fc4bde71");
// owner of 7476: https://opensea.io/0x27b00e6109f246d9d42aaf4a12f0ae35fc4bde71
const [ownership, setOwnerShip] = useState(false)
const [fetching, setFetching] = useState(false);
const handleChange = (prop) => (event) => {
setValues({ ...values, [prop]: event.target.value });
};
const handleChange_ERC = (event) => {
setERC(event.target.value);
};
const handleChange_Class = (event) => {
setClass(event.target.value);
};
// useEffect(() => {
// window.ethereum
// .request({ method: 'eth_requestAccounts' })
// .then((newAccounts) => setAccount(newAccounts[0]));
// }, []);
const fetchData = async () => {
// 使用 await 等待 API 取得回應後才繼續
const balance = await seaport.getAssetBalance({
accountAddress: account,
asset: {
tokenAddress: myclass,
tokenId: values.id,
schemaName: erc
},
})
console.log(balance.toString());
(balance.toString() === "1" ? setOwnerShip(true) : setOwnerShip(false));
};
return (
<div>
<Box
component="form"
sx={{
'& .MuiTextField-root': { m: 1, width: '25ch' },
}}
noValidate
autoComplete="off"
>
<div>
<TextField
id="filled-select-currency"
select
label="Select ERC"
value={erc}
onChange={handleChange_ERC}
helperText="Please select your ERC"
variant="filled"
>
{ERCTypes.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
<TextField
id="filled-select-class"
select
label="Select Class"
value={myclass}
onChange={handleChange_Class}
helperText="Please select your Classes"
variant="filled"
>
{Classes.map((option) => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</TextField>
</div>
</Box>
<Box sx={{ display: 'flex', flexWrap: 'wrap' }}>
<FormControl fullWidth sx={{ m: 1, width: '25ch' }} variant="standard">
<InputLabel htmlFor="standard-adornment-id">Your Certificate ID</InputLabel>
<Input
id="standard-adornment-id"
value={values.id}
onChange={handleChange('id')}
startAdornment={<InputAdornment position="start">ID - </InputAdornment>}
/>
</FormControl>
<Button sx={{ m: 2, width: '26.5ch' }}
onClick={() => {
setFetching(true);
setTimeout(() => {
setFetching(false);
}, 5000);
console.log(erc, myclass, account, values.id);
fetchData();
setOpen(true)
}}
variant="contained">Verify</Button>
<Box sx={{ width: '100%' }}>
<Collapse in={open && ownership && !fetching}>
<Alert
severity="success"
action={
<IconButton
aria-label="close"
color="inherit"
size="small"
onClick={() => {
setOpen(false);
}}
>
<CloseIcon fontSize="inherit" />
</IconButton>
}
sx={{ mb: 2 }}
>
Verify Successfully!
</Alert>
<NFTE
contract={myclass}
tokenId={values.id}
darkMode={true} />
</Collapse>
<Collapse in={open && fetching}>
<Alert
severity="info"
sx={{ mb: 2 }}
icon={<AccessTimeIcon fontSize="inherit" />}
>
Now Verifying...
</Alert>
</Collapse>
<Collapse in={open && !ownership && !fetching}>
<Alert
severity="error"
action={
<IconButton
aria-label="close"
color="inherit"
size="small"
onClick={() => {
setOpen(false);
}}
>
<CloseIcon fontSize="inherit" />
</IconButton>
}
sx={{ mb: 2 }}
>
Verify Failed!
</Alert>
</Collapse>
</Box>
</Box>
</div>
);
}
【Which is your token?】
| Produce NFT |
【Layers Blending & MetaData】
LayerBlending
│
├───index.js
├───package.json
│
├───utils
│ └───updateBaseUri.js
│
├───output
│ ├───metadata.json
│ ├───0~149.json
│ └───0~149.png
│
└───input
├───NFTConfig.js
├───races.txt
├───racesProduction.py
└───part_image
├───1-logo
├───2-congra
├───3-middlelogo
└───4-background
let Static = {
Logo: {
"blue": 0, "green": 0, "yellow": 0, "red": 0, "purple": 0
},
Congra: { "star": 0, "welldone": 0, "green": 0, "science": 0, "pink": 0 },
BackGround: {
"grey": 0, "red": 0, "pink": 0, "yellow": 0, "orange": 0,
"green": 0, "blue": 0, "cloud": 0, "purple": 0
},
Classes: { "A": 0, "B": 0, "C": 0, "D": 0 },
MiddleLogo: { "balloon": 0, "wine": 0, "champagne": 0, "beer": 0, "salute": 0 },
}
function ReviseStatic(_layer) {
let name = _layer.selectedElement.name;
name = name.split('.')[0]
if (_layer.name == "logo") {
Static.Logo[name] += 1;
}
if (_layer.name == "congra") {
Static.Congra[name] += 1;
}
if (_layer.name == "background") {
Static.BackGround[name] += 1;
}
if (_layer.name == "middlelogo") {
Static.MiddleLogo[name] += 1;
}
}
const PrintStatic = () => {
let _static = `
- Produce ${editionSize} KF
- Version: Beta_1.0
--------------------Logo STATIC----------------------------
There are ${Static.Logo.blue} Blue
${Static.Logo.green} Green
${Static.Logo.yellow} Yellow
${Static.Logo.red} Red
${Static.Logo.purple} Purple
--------------------Congra STATIC-------------------------------
There are ${Static.Congra.star} Star
${Static.Congra.welldone} WellDone
${Static.Congra.green} Green
${Static.Congra.science} Science
${Static.Congra.pink} Pink
--------------------MiddleLogo STATIC-------------------------------
There are ${Static.MiddleLogo.balloon} Balloon
${Static.MiddleLogo.wine} Wine
${Static.MiddleLogo.champagne} Champagne
${Static.MiddleLogo.beer} Beer
${Static.MiddleLogo.salute} Salute
--------------------BACKGROUND_COLOR STATIC--------------------
There are ${Static.BackGround.grey} Grey Background
${Static.BackGround.red} Red Background
${Static.BackGround.pink} Pink Background
${Static.BackGround.yellow} Yellow Background
${Static.BackGround.orange} Orange Background
${Static.BackGround.green} Green Background
${Static.BackGround.blue} Blue Background
${Static.BackGround.cloud} Cloud Background
${Static.BackGround.purple} Purple Background
--------------------Classes STATIC------------------------------
There are ${Static.Classes.A} Certificate born at A
${Static.Classes.B} Certificate born at B
${Static.Classes.C} Certificate born at C
${Static.Classes.D} Certificate born at D`
fs.writeFileSync("./output/_Static.txt", _static);
}
"use strict";
const fs = require("fs");
const path = require("path");
const isLocal = typeof process.pkg === "undefined";
const basePath = isLocal ? process.cwd() : path.dirname(process.execPath);
const { baseImageUri } = require("../input/NFTConfig.js");
const baseUri = baseImageUri;
// read json data
let rawdata = fs.readFileSync(`${basePath}/output/json/_metadata.json`);
let data = JSON.parse(rawdata);
data.forEach((item) => {
item.image = `${baseUri}/${item.id}.png`;
fs.writeFileSync(
`${basePath}/output/json/${item.id}.json`,
JSON.stringify(item, null, 2)
);
});
fs.writeFileSync(
`${basePath}/output/json/_metadata.json`,
JSON.stringify(data, null, 2)
);
console.log(`Updated baseUri for images to ===> ${baseUri}`);
| Deploy NFT |
【Structuring Smart Contract】
【Deploy on Testnet】
| Minting dApp |
【Deploy the Lazy Mint in Website】
├───index.jsx
├───mint.jsx
├───Contract
│ └───SmartContract.json
├───Redux
│ │ └───store.js
│ ├───blockchain
│ │ ├───blockchainAction.js
│ │ └───blockchainReducer.js
└───└───data
├───dataAction.js
└───dataReducer.js
blockchainAction.js
// constants
import Web3 from "web3";
import SmartContract from "../../contracts/SmartContract.json";
// log
import { fetchData } from "../data/dataActions";
const connectRequest = () => {
return {
type: "CONNECTION_REQUEST",
};
};
const connectSuccess = (payload) => {
return {
type: "CONNECTION_SUCCESS",
payload: payload,
};
};
const connectFailed = (payload) => {
return {
type: "CONNECTION_FAILED",
payload: payload,
};
};
const updateAccountRequest = (payload) => {
return {
type: "UPDATE_ACCOUNT",
payload: payload,
};
};
export const connect = () => {
return async (dispatch) => {
dispatch(connectRequest());
if (window.ethereum) {
let web3 = new Web3(window.ethereum);
try {
const accounts = await window.ethereum.request({
method: "eth_requestAccounts",
});
const networkId = await window.ethereum.request({
method: "net_version",
});
// const NetworkData = await SmartContract.networks[networkId];
if (networkId == 4) {
const SmartContractObj = new web3.eth.Contract(
SmartContract.abi,
// NetworkData.address
"0xC904c79AF32eEDd3b4d6E88A0f18CCe3f28837B9"
);
dispatch(
connectSuccess({
account: accounts[0],
smartContract: SmartContractObj,
web3: web3,
})
);
// Add listeners start
window.ethereum.on("accountsChanged", (accounts) => {
dispatch(updateAccount(accounts[0]));
});
window.ethereum.on("chainChanged", () => {
window.location.reload();
});
// Add listeners end
} else {
dispatch(connectFailed("Change network to Polygon."));
}
} catch (err) {
dispatch(connectFailed("Something went wrong."));
}
} else {
dispatch(connectFailed("Install Metamask."));
}
};
};
export const updateAccount = (account) => {
return async (dispatch) => {
dispatch(updateAccountRequest({ account: account }));
dispatch(fetchData(account));
};
};
blockchainReducer.js
const initialState = {
loading: false,
account: null,
smartContract: null,
web3: null,
errorMsg: "",
};
const blockchainReducer = (state = initialState, action) => {
switch (action.type) {
case "CONNECTION_REQUEST":
return {
...initialState,
loading: true,
};
case "CONNECTION_SUCCESS":
return {
...state,
loading: false,
account: action.payload.account,
smartContract: action.payload.smartContract,
web3: action.payload.web3,
};
case "CONNECTION_FAILED":
return {
...initialState,
loading: false,
errorMsg: action.payload,
};
case "UPDATE_ACCOUNT":
return {
...state,
account: action.payload.account,
};
default:
return state;
}
};
export default blockchainReducer;
dataActions.js
// log
import store from "../store";
const fetchDataRequest = () => {
return {
type: "CHECK_DATA_REQUEST",
};
};
const fetchDataSuccess = (payload) => {
return {
type: "CHECK_DATA_SUCCESS",
payload: payload,
};
};
const fetchDataFailed = (payload) => {
return {
type: "CHECK_DATA_FAILED",
payload: payload,
};
};
export const fetchData = (account) => {
return async (dispatch) => {
dispatch(fetchDataRequest());
try {
let name = await store
.getState()
.blockchain.smartContract.methods.name()
.call();
dispatch(
fetchDataSuccess({
name,
})
);
} catch (err) {
console.log(err);
dispatch(fetchDataFailed("Could not load data from contract."));
}
};
};
dataReducers.js
const initialState = {
loading: false,
name: "",
error: false,
errorMsg: "",
};
const dataReducer = (state = initialState, action) => {
switch (action.type) {
case "CHECK_DATA_REQUEST":
return {
...initialState,
loading: true,
};
case "CHECK_DATA_SUCCESS":
return {
...initialState,
loading: false,
name: action.payload.name,
};
case "CHECK_DATA_FAILED":
return {
...initialState,
loading: false,
error: true,
errorMsg: action.payload,
};
default:
return state;
}
};
export default dataReducer;
store.js
import { applyMiddleware, compose, createStore, combineReducers } from "redux";
import thunk from "redux-thunk";
import blockchainReducer from "./blockchain/blockchainReducer";
import dataReducer from "./data/dataReducer";
const rootReducer = combineReducers({
blockchain: blockchainReducer,
data: dataReducer,
});
const middleware = [thunk];
const composeEnhancers = compose(applyMiddleware(...middleware));
const configureStore = () => {
return createStore(rootReducer, composeEnhancers);
};
const store = configureStore();
export default store;
【Deploy on Mainnet】
把圖片放在乙太坊上到底要多少錢
1 mb 的圖片
每個 uint 是 256bits = 32 bytes = 2^5 bytes 1MB = 1024 * 1024 = 2 ^ 20 (指數律ㄏㄏ)
2^20 / 2^5 = 2^15 = 32768
32768 * 20000 + 21000 = 655381000 GAS
655381000 GAS * 70 Gwei = 45876670000
10^9 Gwei = 1 eth
45876670000 / 10^9 = 45.8767 eth
118857.35436 USD
Reference and Some Resources
論壇、群組或者是學習資源
Last updated