diff --git a/members/Old000Driver/readme.md b/members/Old000Driver/readme.md new file mode 100644 index 000000000..ae2f61888 --- /dev/null +++ b/members/Old000Driver/readme.md @@ -0,0 +1,15 @@ +# Web3 前端训练营报名 + +## 个人信息 + +* GitHub ID:Old000Driver +* OpenBuild Username:792002710 +* ERC20 钱包地址:0x2282050684b096d36393e3d60F4bBa3f3d0cC661 + +### 个人介绍 + +`有4年前端开发经验,从事过网络安全行业和金融行业,目前已经学习并练习过 solidity、etherjs、nextjs、tailwind,以成为 web3 合格前端为目标继续补充学习` + +## 任务提交 + +`建议所有的作业结果在个人的 GitHub 下单独建立仓库放置,在此 readme 里只当相关链接或简介描述即可` diff --git a/members/Old000Driver/task1/.gitignore b/members/Old000Driver/task1/.gitignore new file mode 100644 index 000000000..a547bf36d --- /dev/null +++ b/members/Old000Driver/task1/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/members/Old000Driver/task1/README.md b/members/Old000Driver/task1/README.md new file mode 100644 index 000000000..74872fd4a --- /dev/null +++ b/members/Old000Driver/task1/README.md @@ -0,0 +1,50 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type aware lint rules: + +- Configure the top-level `parserOptions` property like this: + +```js +export default tseslint.config({ + languageOptions: { + // other options... + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + }, +}) +``` + +- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked` +- Optionally add `...tseslint.configs.stylisticTypeChecked` +- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config: + +```js +// eslint.config.js +import react from 'eslint-plugin-react' + +export default tseslint.config({ + // Set the react version + settings: { react: { version: '18.3' } }, + plugins: { + // Add the react plugin + react, + }, + rules: { + // other rules... + // Enable its recommended rules + ...react.configs.recommended.rules, + ...react.configs['jsx-runtime'].rules, + }, +}) +``` diff --git a/members/Old000Driver/task1/eslint.config.js b/members/Old000Driver/task1/eslint.config.js new file mode 100644 index 000000000..092408a9f --- /dev/null +++ b/members/Old000Driver/task1/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' + +export default tseslint.config( + { ignores: ['dist'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.recommended], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +) diff --git a/members/Old000Driver/task1/index.html b/members/Old000Driver/task1/index.html new file mode 100644 index 000000000..480c218ca --- /dev/null +++ b/members/Old000Driver/task1/index.html @@ -0,0 +1,14 @@ + + + + + + + + Vite + React + TS + + +
+ + + diff --git a/members/Old000Driver/task1/package.json b/members/Old000Driver/task1/package.json new file mode 100644 index 000000000..3487f93ab --- /dev/null +++ b/members/Old000Driver/task1/package.json @@ -0,0 +1,29 @@ +{ + "name": "openbuildtasks", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "typescript": "~5.6.2", + "typescript-eslint": "^8.18.2", + "vite": "^6.0.5" + } +} diff --git a/members/Old000Driver/task1/public/vite.svg b/members/Old000Driver/task1/public/vite.svg new file mode 100644 index 000000000..e7b8dfb1b --- /dev/null +++ b/members/Old000Driver/task1/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/members/Old000Driver/task1/src/App.tsx b/members/Old000Driver/task1/src/App.tsx new file mode 100644 index 000000000..077368158 --- /dev/null +++ b/members/Old000Driver/task1/src/App.tsx @@ -0,0 +1,60 @@ +import { useState, useEffect } from "react"; +import Header from "./components/Header"; +import ToDoList from "./components/ToDoList"; +import AddToDo from "./components/AddToDo"; + +export interface Todo { + id: number; + text: string; + completed: boolean; +} + +function App() { + const [todos, setTodos] = useState(() => { + const savedTodos = localStorage.getItem("todos"); + return savedTodos ? JSON.parse(savedTodos) : []; + }); + + useEffect(() => { + localStorage.setItem("todos", JSON.stringify(todos)); + }, [todos]); + + const addTodo = (text: string) => { + if (text.trim()) { + setTodos([ + ...todos, + { + id: Date.now(), + text: text.trim(), + completed: false, + }, + ]); + } + }; + + const deleteTodo = (id: number) => { + setTodos(todos.filter((todo) => todo.id !== id)); + }; + + const toggleTodo = (id: number) => { + setTodos( + todos.map((todo) => + todo.id === id ? { ...todo, completed: !todo.completed } : todo + ) + ); + }; + + return ( +
+
+
+
+ + +
+
+
+ ); +} + +export default App; diff --git a/members/Old000Driver/task1/src/assets/react.svg b/members/Old000Driver/task1/src/assets/react.svg new file mode 100644 index 000000000..6c87de9bb --- /dev/null +++ b/members/Old000Driver/task1/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/members/Old000Driver/task1/src/components/AddToDo.tsx b/members/Old000Driver/task1/src/components/AddToDo.tsx new file mode 100644 index 000000000..cb0fb05c1 --- /dev/null +++ b/members/Old000Driver/task1/src/components/AddToDo.tsx @@ -0,0 +1,34 @@ +import React, { useState } from "react"; + +interface AddToDoProps { + onAdd: (text: string) => void; +} + +const AddToDo: React.FC = ({ onAdd }) => { + const [text, setText] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + onAdd(text); + setText(""); + }; + + return ( +
+
+ setText(e.target.value)} + placeholder="添加新的待办事项" + className="add-todo-input" + /> + +
+
+ ); +}; + +export default AddToDo; diff --git a/members/Old000Driver/task1/src/components/Header.tsx b/members/Old000Driver/task1/src/components/Header.tsx new file mode 100644 index 000000000..ec48b4e9b --- /dev/null +++ b/members/Old000Driver/task1/src/components/Header.tsx @@ -0,0 +1,11 @@ +import React from 'react' + +const Header: React.FC = () => { + return ( +
+

待办事项列表

+
+ ) +} + +export default Header \ No newline at end of file diff --git a/members/Old000Driver/task1/src/components/ToDoItem.tsx b/members/Old000Driver/task1/src/components/ToDoItem.tsx new file mode 100644 index 000000000..9d5959c13 --- /dev/null +++ b/members/Old000Driver/task1/src/components/ToDoItem.tsx @@ -0,0 +1,36 @@ +import React from 'react' +import { Todo } from '../App' + +interface ToDoItemProps { + todo: Todo + onDelete: (id: number) => void + onToggle: (id: number) => void +} + +const ToDoItem: React.FC = ({ todo, onDelete, onToggle }) => { + return ( +
+
+
+ +
+ ) +} + +export default ToDoItem \ No newline at end of file diff --git a/members/Old000Driver/task1/src/components/ToDoList.tsx b/members/Old000Driver/task1/src/components/ToDoList.tsx new file mode 100644 index 000000000..339bbddea --- /dev/null +++ b/members/Old000Driver/task1/src/components/ToDoList.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import ToDoItem from "./ToDoItem"; +import { Todo } from "../App"; + +interface ToDoListProps { + todos: Todo[]; + onDelete: (id: number) => void; + onToggle: (id: number) => void; +} + +const ToDoList: React.FC = ({ todos, onDelete, onToggle }) => { + return ( +
+ {todos.map((todo) => ( + + ))} +
+ ); +}; + +export default ToDoList; diff --git a/members/Old000Driver/task1/src/index.css b/members/Old000Driver/task1/src/index.css new file mode 100644 index 000000000..9a51dc2af --- /dev/null +++ b/members/Old000Driver/task1/src/index.css @@ -0,0 +1,177 @@ +/* 基础样式 */ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* 容器样式 */ +.container { + min-height: 100vh; + background-color: white; +} + +.content { + width: auto; + margin: 0 auto; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 2rem; + justify-content: center; + align-items: center; +} + +/* Header 样式 */ +.header { + text-align: center; + margin-bottom: 2rem; +} + +.header h1 { + font-size: 1.875rem; + font-weight: bold; + color: #111827; +} + +/* AddToDo 样式 */ +.add-todo-form { + display: flex; + gap: 0.75rem; + margin-bottom: 1.5rem; + width: 400px; + margin-left: auto; + margin-right: auto; +} + +.add-todo-input { + flex: 1; + padding: 0.5rem 1rem; + border: 1px solid #d1d5db; + border-radius: 0.5rem; + outline: none; +} + +.add-todo-input:focus { + border-color: #3b82f6; +} + +.add-button { + min-width: 80px; + padding: 0.5rem 1.5rem; + background-color: #0ea5e9; + color: white; + font-weight: 500; + border: none; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s ease-in-out; +} + +.add-button:hover { + background-color: #0284c7; +} + +/* ToDoList 样式 */ +.todo-list { + width: 400px; + margin-left: auto; + margin-right: auto; +} + +.todo-list > * + * { + margin-top: 0.75rem; +} + +.todo-container { + padding: 36px; + border: 2px solid black; + width: 400px; + border-radius: 25px; + box-shadow: #111827; +} + +/* ToDoItem 样式 */ +.todo-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + background-color: #f9fafb; + border-radius: 0.5rem; +} + +.todo-item.completed { + opacity: 0.6; +} + +.todo-text { + cursor: pointer; + color: #111827; +} + +.todo-text.completed { + text-decoration: line-through; +} + +.delete-button { + min-width: 60px; + padding: 0.5rem 1rem; + background-color: #ef4444; + color: white; + font-weight: 500; + border: none; + border-radius: 0.5rem; + cursor: pointer; + transition: all 0.2s ease-in-out; +} + +.delete-button:hover { + background-color: #dc2626; +} + +/* 添加复选框按钮样式 */ +.toggle-button { + width: 24px; + height: 24px; + border: 2px solid #d1d5db; + border-radius: 50%; + margin-right: 12px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + background: white; + transition: all 0.2s ease-in-out; +} + +.toggle-button.completed { + background: #10b981; + border-color: #10b981; +} + +.toggle-button.completed::after { + content: "✓"; + color: white; + font-size: 14px; +} + +/* 修改 todo-item 样式 */ +.todo-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem; + background-color: #f9fafb; + border-radius: 0.5rem; +} + +.todo-left { + display: flex; + align-items: center; + flex: 1; +} diff --git a/members/Old000Driver/task1/src/main.tsx b/members/Old000Driver/task1/src/main.tsx new file mode 100644 index 000000000..bef5202a3 --- /dev/null +++ b/members/Old000Driver/task1/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/members/Old000Driver/task1/src/vite-env.d.ts b/members/Old000Driver/task1/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/members/Old000Driver/task1/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/members/Old000Driver/task1/tsconfig.app.json b/members/Old000Driver/task1/tsconfig.app.json new file mode 100644 index 000000000..358ca9ba9 --- /dev/null +++ b/members/Old000Driver/task1/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/members/Old000Driver/task1/tsconfig.json b/members/Old000Driver/task1/tsconfig.json new file mode 100644 index 000000000..1ffef600d --- /dev/null +++ b/members/Old000Driver/task1/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/members/Old000Driver/task1/tsconfig.node.json b/members/Old000Driver/task1/tsconfig.node.json new file mode 100644 index 000000000..db0becc8b --- /dev/null +++ b/members/Old000Driver/task1/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/members/Old000Driver/task1/vite.config.ts b/members/Old000Driver/task1/vite.config.ts new file mode 100644 index 000000000..0e43ae8de --- /dev/null +++ b/members/Old000Driver/task1/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}); diff --git a/members/Old000Driver/task2/bind-wallet.png b/members/Old000Driver/task2/bind-wallet.png new file mode 100644 index 000000000..b468ffdf2 Binary files /dev/null and b/members/Old000Driver/task2/bind-wallet.png differ diff --git a/members/Old000Driver/task2/readme.md b/members/Old000Driver/task2/readme.md new file mode 100644 index 000000000..7fa15420f --- /dev/null +++ b/members/Old000Driver/task2/readme.md @@ -0,0 +1,68 @@ +# Task2 Blockchain Basic + +## [单选题] 如果你莫名奇妙收到了一个 NFT,那么 + +- [ ] 天上掉米,我应该马上点开他的链接 +- [☑️] 这可能是在对我进行诈骗! + +## [单选题] 群里大哥给我发的网站,说能赚大米,我应该 + +- [ ] 赶紧冲啊,待会米被人抢了 +- [☑️] 谨慎判断,不在不信任的网站链接钱包 + +## [单选题] 下列说法正确的是 + +- [☑️] 一个私钥对应一个地址 +- [ ] 一个私钥对应多个地址 +- [ ] 多个私钥对应一个地址 +- [ ] 多个私钥对应多个地址 + +## [单选题] 下列哪个是以太坊虚拟机的简称 + +- [ ] CLR +- [☑️] EVM +- [ ] JVM + +## [单选题] 以下哪个是以太坊上正确的地址格式? + +- [ ] 1A4BHoT2sXFuHsyL6bnTcD1m6AP9C5uyT1 +- [ ] TEEuMMSc6zPJD36gfjBAR2GmqT6Tu1Rcut +- [ ] 0x997fd71a4cf5d214009619808176b947aec122890a7fcee02e78e329596c94ba +- [☑️] 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + +## [多选题] 有一天某个大哥说要按市场价的 80% 出油给你,有可能 + +- [☑️] 他在洗米 +- [☑️] 他良心发现 +- [☑️] 要给我黒米 +- [☑️] 给我下套呢 + +## [多选题] 以下哪些是以太坊的二层扩容方案? + +- [ ] Lightning Network(闪电网络) +- [☑️] Optimsitic Rollup +- [☑️] Zk Rollup + +## [简答题] 简述区块链的网络结构 + +节点、区块、链、共识机制、网络协议、智能合约、去中心化应用、用户钱包、矿工 + +## [简答题] 智能合约是什么,有何作用? + +智能合约由代码控制业务部署在区块链上,可以在链上提供安全、可靠的服务,不受人为干扰。 + +## [简答题] 怎么理解大家常说的 `EVM` 这个词汇? + +EVM 是一个虚拟机,专门设计用于执行智能合约。它是以太坊网络中的计算引擎,负责处理和执行所有智能合约代码 + +## [分析题] 你对去中心化的理解 + +去中心化是民主的体现,虽然会导致效率低下,但是可以保证公平、公正,不受个人或组织的控制。区块链技术的应用,使得金融上去中心化成为可能。鉴于我们的国情可能难以大面积推广,但是在一些特定的领域,比如金融领域,去中心化的应用还是有很大的前景。 + +## [分析题] 比较区块链与传统数据库,你的看法? + +传统数据由中心化的数据库管理,容易被篡改,而区块链是去中心化的,数据不可篡改。传统数据库的数据安全性不高,而区块链的数据安全性高。传统数据库的数据难以追溯,而区块链的数据可以追溯。传统数据库的数据不透明,而区块链的数据透明。 + +## 操作题 + +![alt text](image.png) diff --git a/members/Old000Driver/task3/.gitignore b/members/Old000Driver/task3/.gitignore new file mode 100644 index 000000000..115ec00a4 --- /dev/null +++ b/members/Old000Driver/task3/.gitignore @@ -0,0 +1,21 @@ +node_modules +.env + +# Hardhat files +/cache +/artifacts + +# TypeChain files +/typechain +/typechain-types + +# solidity-coverage files +/coverage +/coverage.json + +# Hardhat Ignition default folder for deployments against a local node +ignition/deployments/chain-31337 + +.env + +*.markdown \ No newline at end of file diff --git a/members/Old000Driver/task3/README.md b/members/Old000Driver/task3/README.md new file mode 100644 index 000000000..d04248dab --- /dev/null +++ b/members/Old000Driver/task3/README.md @@ -0,0 +1,13 @@ +# Sample Hardhat Project + +This project demonstrates a basic Hardhat use case. It comes with a sample contract, a test for that contract, and a Hardhat Ignition module that deploys that contract. + +Try running some of the following tasks: + +```shell +npx hardhat help +npx hardhat test +REPORT_GAS=true npx hardhat test +npx hardhat node +npx hardhat ignition deploy ./ignition/modules/Lock.js +``` diff --git a/members/Old000Driver/task3/contracts/Lock.sol b/members/Old000Driver/task3/contracts/Lock.sol new file mode 100644 index 000000000..2f385f70e --- /dev/null +++ b/members/Old000Driver/task3/contracts/Lock.sol @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: UNLICENSED +pragma solidity ^0.8.28; + +// Uncomment this line to use console.log +// import "hardhat/console.sol"; + +contract Lock { + uint public unlockTime; + address payable public owner; + + event Withdrawal(uint amount, uint when); + + constructor(uint _unlockTime) payable { + require( + block.timestamp < _unlockTime, + "Unlock time should be in the future" + ); + + unlockTime = _unlockTime; + owner = payable(msg.sender); + } + + function withdraw() public { + // Uncomment this line, and the import of "hardhat/console.sol", to print a log in your terminal + // console.log("Unlock time is %o and block timestamp is %o", unlockTime, block.timestamp); + + require(block.timestamp >= unlockTime, "You can't withdraw yet"); + require(msg.sender == owner, "You aren't the owner"); + + emit Withdrawal(address(this).balance, block.timestamp); + + owner.transfer(address(this).balance); + } +} diff --git a/members/Old000Driver/task3/contracts/MyNFT.sol b/members/Old000Driver/task3/contracts/MyNFT.sol new file mode 100644 index 000000000..bf93b5108 --- /dev/null +++ b/members/Old000Driver/task3/contracts/MyNFT.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC721/ERC721.sol"; +import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol"; + +contract MyNFT is ERC721URIStorage { + uint256 private _tokenIdCounter; + + constructor() ERC721("MyNFT", "MNFT") {} + + function mint(address to, string memory tokenURI) public returns (uint256) { + uint256 tokenId = _tokenIdCounter++; + _mint(to, tokenId); + _setTokenURI(tokenId, tokenURI); + return tokenId; + } +} \ No newline at end of file diff --git a/members/Old000Driver/task3/contracts/MyToken.sol b/members/Old000Driver/task3/contracts/MyToken.sol new file mode 100644 index 000000000..59e0738ba --- /dev/null +++ b/members/Old000Driver/task3/contracts/MyToken.sol @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MyToken is ERC20 { + constructor(uint256 initialSupply) ERC20("MyToken", "MTK") { + _mint(msg.sender, initialSupply); + } +} \ No newline at end of file diff --git a/members/Old000Driver/task3/contracts/NFTMarket.sol b/members/Old000Driver/task3/contracts/NFTMarket.sol new file mode 100644 index 000000000..3244cf18c --- /dev/null +++ b/members/Old000Driver/task3/contracts/NFTMarket.sol @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import "@openzeppelin/contracts/token/ERC721/IERC721.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; + +contract NFTMarket { + struct Listing { + address seller; + uint256 price; + address erc20Token; + } + + // 添加新的状态变量来跟踪所有上架的NFT + struct ListedItem { + address nftContract; + uint256 tokenId; + } + + ListedItem[] public allListedItems; + // 用于快速查找ListedItem在数组中的位置 + mapping(address => mapping(uint256 => uint256)) private listingIndex; + + // 映射:NFT合约地址 => TokenID => 上架信息 + mapping(address => mapping(uint256 => Listing)) public listings; + + // 事件声明 + event ItemListed( + address indexed seller, + address indexed nftContract, + uint256 indexed tokenId, + uint256 price, + address erc20Token + ); + + event ItemPurchased( + address indexed buyer, + address indexed nftContract, + uint256 indexed tokenId, + uint256 price, + address erc20Token + ); + + // 添加下架事件 + event ItemDelisted( + address indexed seller, + address indexed nftContract, + uint256 indexed tokenId + ); + + // 上架NFT + function listNFT( + address nftContract, + uint256 tokenId, + uint256 price, + address erc20Token + ) external { + IERC721(nftContract).transferFrom(msg.sender, address(this), tokenId); + listings[nftContract][tokenId] = Listing({ + seller: msg.sender, + price: price, + erc20Token: erc20Token + }); + + // 添加到已上架NFT列表 + listingIndex[nftContract][tokenId] = allListedItems.length; + allListedItems.push(ListedItem(nftContract, tokenId)); + + emit ItemListed(msg.sender, nftContract, tokenId, price, erc20Token); + } + + function _removeListing(address nftContract, uint256 tokenId) private { + uint256 index = listingIndex[nftContract][tokenId]; + uint256 lastIndex = allListedItems.length - 1; + + if (index != lastIndex) { + // 将最后一个元素移动到要删除的位置 + ListedItem memory lastItem = allListedItems[lastIndex]; + allListedItems[index] = lastItem; + listingIndex[lastItem.nftContract][lastItem.tokenId] = index; + } + + // 删除最后一个元素 + allListedItems.pop(); + delete listingIndex[nftContract][tokenId]; + } + + // 购买NFT + function buyNFT(address nftContract, uint256 tokenId) external { + Listing memory listing = listings[nftContract][tokenId]; + require(listing.price > 0, "Item not listed"); + + // 转移ERC20代币 + IERC20(listing.erc20Token).transferFrom( + msg.sender, + listing.seller, + listing.price + ); + + // 转移NFT + IERC721(nftContract).transferFrom(address(this), msg.sender, tokenId); + + // 清除上架信息 + delete listings[nftContract][tokenId]; + + _removeListing(nftContract, tokenId); + + emit ItemPurchased( + msg.sender, + nftContract, + tokenId, + listing.price, + listing.erc20Token + ); + } + + // 下架NFT + function delistNFT(address nftContract, uint256 tokenId) external { + Listing memory listing = listings[nftContract][tokenId]; + require(listing.seller == msg.sender, "Not the seller"); + + // 将NFT归还给卖家 + IERC721(nftContract).transferFrom(address(this), msg.sender, tokenId); + + // 清除上架信息 + delete listings[nftContract][tokenId]; + + _removeListing(nftContract, tokenId); + + emit ItemDelisted(msg.sender, nftContract, tokenId); + } + + // 获取NFT上架信息 + function getListing( + address nftContract, + uint256 tokenId + ) + external + view + returns (address seller, uint256 price, address erc20Token) + { + Listing memory listing = listings[nftContract][tokenId]; + return (listing.seller, listing.price, listing.erc20Token); + } + + // 检查NFT是否已上架 + function isListed( + address nftContract, + uint256 tokenId + ) external view returns (bool) { + return listings[nftContract][tokenId].price > 0; + } + + // 获取所有上架的NFT信息 + function getAllListedNFTs() external view returns ( + ListedItem[] memory items, + address[] memory sellers, + uint256[] memory prices, + address[] memory erc20Tokens + ) { + uint256 length = allListedItems.length; + items = allListedItems; + sellers = new address[](length); + prices = new uint256[](length); + erc20Tokens = new address[](length); + + for (uint256 i = 0; i < length; i++) { + Listing memory listing = listings[items[i].nftContract][items[i].tokenId]; + sellers[i] = listing.seller; + prices[i] = listing.price; + erc20Tokens[i] = listing.erc20Token; + } + + return (items, sellers, prices, erc20Tokens); + } + + // 获取上架NFT的总数 + function getListedNFTCount() external view returns (uint256) { + return allListedItems.length; + } +} diff --git a/members/Old000Driver/task3/hardhat.config.js b/members/Old000Driver/task3/hardhat.config.js new file mode 100644 index 000000000..edf758be2 --- /dev/null +++ b/members/Old000Driver/task3/hardhat.config.js @@ -0,0 +1,42 @@ +require("@nomicfoundation/hardhat-toolbox"); +require("@nomicfoundation/hardhat-verify"); +require("dotenv").config(); + +/** @type import('hardhat/config').HardhatUserConfig */ +module.exports = { + solidity: { + version: "0.8.28", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + }, + }, + networks: { + hardhat: { + chainId: 31337, + }, + sepolia: { + url: process.env.SEPOLIA_RPC_URL, + accounts: [process.env.PRIVATE_KEY], + timeout: 60000, // 增加超时时间到60秒 + }, + }, + etherscan: { + apiKey: process.env.ETHERSCAN_API_KEY, // 简化配置 + customChains: [], // 如果需要自定义链,可以在这里添加 + }, + sourcify: { + enabled: true, // 启用 Sourcify 验证 + }, + paths: { + sources: "./contracts", + tests: "./test", + cache: "./cache", + artifacts: "./artifacts", + }, + mocha: { + timeout: 60000, // 增加测试超时时间 + }, +}; diff --git a/members/Old000Driver/task3/ignition/modules/Lock.js b/members/Old000Driver/task3/ignition/modules/Lock.js new file mode 100644 index 000000000..32c7cd413 --- /dev/null +++ b/members/Old000Driver/task3/ignition/modules/Lock.js @@ -0,0 +1,18 @@ +// This setup uses Hardhat Ignition to manage smart contract deployments. +// Learn more about it at https://hardhat.org/ignition + +const { buildModule } = require("@nomicfoundation/hardhat-ignition/modules"); + +const JAN_1ST_2030 = 1893456000; +const ONE_GWEI = 1_000_000_000n; + +module.exports = buildModule("LockModule", (m) => { + const unlockTime = m.getParameter("unlockTime", JAN_1ST_2030); + const lockedAmount = m.getParameter("lockedAmount", ONE_GWEI); + + const lock = m.contract("Lock", [unlockTime], { + value: lockedAmount, + }); + + return { lock }; +}); diff --git a/members/Old000Driver/task3/package.json b/members/Old000Driver/task3/package.json new file mode 100644 index 000000000..df341dbed --- /dev/null +++ b/members/Old000Driver/task3/package.json @@ -0,0 +1,16 @@ +{ + "name": "hardhat-project", + "devDependencies": { + "@nomicfoundation/hardhat-chai-matchers": "^2.0.0", + "@nomicfoundation/hardhat-ethers": "^3.0.0", + "@nomicfoundation/hardhat-toolbox": "^5.0.0", + "@nomicfoundation/hardhat-verify": "^2.0.12", + "chai": "^4.3.7", + "ethers": "^6.13.5", + "hardhat": "^2.22.18" + }, + "dependencies": { + "@openzeppelin/contracts": "^5.2.0", + "dotenv": "^16.4.7" + } +} diff --git a/members/Old000Driver/task3/scripts/deploy.js b/members/Old000Driver/task3/scripts/deploy.js new file mode 100644 index 000000000..40de6bcb7 --- /dev/null +++ b/members/Old000Driver/task3/scripts/deploy.js @@ -0,0 +1,26 @@ +const hre = require("hardhat"); + +async function main() { + // 部署 ERC20 代币 + const MyToken = await hre.ethers.getContractFactory("MyToken"); + const erc20 = await MyToken.deploy(ethers.parseEther("1000000")); // 初始发行 100 万代币 + await erc20.waitForDeployment(); + console.log("ERC20 deployed to:", await erc20.getAddress()); + + // 部署 NFT 合约 + const MyNFT = await hre.ethers.getContractFactory("MyNFT"); + const nft = await MyNFT.deploy(); + await nft.waitForDeployment(); + console.log("NFT deployed to:", await nft.getAddress()); + + // 部署市场合约 + const NFTMarket = await hre.ethers.getContractFactory("NFTMarket"); + const market = await NFTMarket.deploy(); + await market.waitForDeployment(); + console.log("Market deployed to:", await market.getAddress()); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); \ No newline at end of file diff --git a/members/Old000Driver/task3/scripts/deployMarket.js b/members/Old000Driver/task3/scripts/deployMarket.js new file mode 100644 index 000000000..1955776d5 --- /dev/null +++ b/members/Old000Driver/task3/scripts/deployMarket.js @@ -0,0 +1,48 @@ +const hre = require("hardhat"); + +async function main() { + try { + console.log("开始部署 NFTMarket 合约..."); + + // 部署市场合约 + const NFTMarket = await hre.ethers.getContractFactory("NFTMarket"); + const market = await NFTMarket.deploy(); + await market.waitForDeployment(); + + const marketAddress = await market.getAddress(); + console.log("Market deployed to:", marketAddress); + + // 等待几个区块确认 + console.log("等待区块确认..."); + await market.deploymentTransaction().wait(5); + + // 验证合约 + if (network.name !== "hardhat" && network.name !== "localhost") { + console.log("开始验证合约..."); + try { + await hre.run("verify:verify", { + address: marketAddress, + constructorArguments: [], + contract: "contracts/NFTMarket.sol:NFTMarket" + }); + console.log("合约验证成功!"); + } catch (error) { + if (error.message.includes("Already Verified")) { + console.log("合约已经验证过了"); + } else { + console.error("合约验证失败:", error); + } + } + } + } catch (error) { + console.error("部署过程出错:", error); + process.exitCode = 1; + } +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/members/Old000Driver/task3/test-env.js b/members/Old000Driver/task3/test-env.js new file mode 100644 index 000000000..5ccd96d1d --- /dev/null +++ b/members/Old000Driver/task3/test-env.js @@ -0,0 +1,2 @@ +require("dotenv").config(); +console.log("ETHERSCAN_API_KEY:", process.env.ETHERSCAN_API_KEY); \ No newline at end of file diff --git a/members/Old000Driver/task3/test/Lock.js b/members/Old000Driver/task3/test/Lock.js new file mode 100644 index 000000000..f0e6ba1b2 --- /dev/null +++ b/members/Old000Driver/task3/test/Lock.js @@ -0,0 +1,126 @@ +const { + time, + loadFixture, +} = require("@nomicfoundation/hardhat-toolbox/network-helpers"); +const { anyValue } = require("@nomicfoundation/hardhat-chai-matchers/withArgs"); +const { expect } = require("chai"); + +describe("Lock", function () { + // We define a fixture to reuse the same setup in every test. + // We use loadFixture to run this setup once, snapshot that state, + // and reset Hardhat Network to that snapshot in every test. + async function deployOneYearLockFixture() { + const ONE_YEAR_IN_SECS = 365 * 24 * 60 * 60; + const ONE_GWEI = 1_000_000_000; + + const lockedAmount = ONE_GWEI; + const unlockTime = (await time.latest()) + ONE_YEAR_IN_SECS; + + // Contracts are deployed using the first signer/account by default + const [owner, otherAccount] = await ethers.getSigners(); + + const Lock = await ethers.getContractFactory("Lock"); + const lock = await Lock.deploy(unlockTime, { value: lockedAmount }); + + return { lock, unlockTime, lockedAmount, owner, otherAccount }; + } + + describe("Deployment", function () { + it("Should set the right unlockTime", async function () { + const { lock, unlockTime } = await loadFixture(deployOneYearLockFixture); + + expect(await lock.unlockTime()).to.equal(unlockTime); + }); + + it("Should set the right owner", async function () { + const { lock, owner } = await loadFixture(deployOneYearLockFixture); + + expect(await lock.owner()).to.equal(owner.address); + }); + + it("Should receive and store the funds to lock", async function () { + const { lock, lockedAmount } = await loadFixture( + deployOneYearLockFixture + ); + + expect(await ethers.provider.getBalance(lock.target)).to.equal( + lockedAmount + ); + }); + + it("Should fail if the unlockTime is not in the future", async function () { + // We don't use the fixture here because we want a different deployment + const latestTime = await time.latest(); + const Lock = await ethers.getContractFactory("Lock"); + await expect(Lock.deploy(latestTime, { value: 1 })).to.be.revertedWith( + "Unlock time should be in the future" + ); + }); + }); + + describe("Withdrawals", function () { + describe("Validations", function () { + it("Should revert with the right error if called too soon", async function () { + const { lock } = await loadFixture(deployOneYearLockFixture); + + await expect(lock.withdraw()).to.be.revertedWith( + "You can't withdraw yet" + ); + }); + + it("Should revert with the right error if called from another account", async function () { + const { lock, unlockTime, otherAccount } = await loadFixture( + deployOneYearLockFixture + ); + + // We can increase the time in Hardhat Network + await time.increaseTo(unlockTime); + + // We use lock.connect() to send a transaction from another account + await expect(lock.connect(otherAccount).withdraw()).to.be.revertedWith( + "You aren't the owner" + ); + }); + + it("Shouldn't fail if the unlockTime has arrived and the owner calls it", async function () { + const { lock, unlockTime } = await loadFixture( + deployOneYearLockFixture + ); + + // Transactions are sent using the first signer by default + await time.increaseTo(unlockTime); + + await expect(lock.withdraw()).not.to.be.reverted; + }); + }); + + describe("Events", function () { + it("Should emit an event on withdrawals", async function () { + const { lock, unlockTime, lockedAmount } = await loadFixture( + deployOneYearLockFixture + ); + + await time.increaseTo(unlockTime); + + await expect(lock.withdraw()) + .to.emit(lock, "Withdrawal") + .withArgs(lockedAmount, anyValue); // We accept any value as `when` arg + }); + }); + + describe("Transfers", function () { + it("Should transfer the funds to the owner", async function () { + const { lock, unlockTime, lockedAmount, owner } = await loadFixture( + deployOneYearLockFixture + ); + + await time.increaseTo(unlockTime); + + await expect(lock.withdraw()).to.changeEtherBalances( + [owner, lock], + [lockedAmount, -lockedAmount] + ); + }); + }); + }); +}); diff --git a/members/Old000Driver/task3/test/Market.test.js b/members/Old000Driver/task3/test/Market.test.js new file mode 100644 index 000000000..7342c127b --- /dev/null +++ b/members/Old000Driver/task3/test/Market.test.js @@ -0,0 +1,237 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); +const { parseEther } = ethers; + +describe("NFTMarket", function () { + let nftMarket; + let myNFT; + let myToken; + let owner; + let buyer; + let seller; + const TOKEN_ID = 0; + const PRICE = parseEther("1.0"); + + beforeEach(async function () { + [owner, seller, buyer] = await ethers.getSigners(); + + // 部署 NFT 合约 + const MyNFT = await ethers.getContractFactory("MyNFT"); + myNFT = await MyNFT.deploy(); + await myNFT.waitForDeployment(); + const nftAddress = await myNFT.getAddress(); // 获取合约地址 + + // 部署 ERC20 合约 + const MyToken = await ethers.getContractFactory("MyToken"); + myToken = await MyToken.deploy(parseEther("10000")); + await myToken.waitForDeployment(); + const tokenAddress = await myToken.getAddress(); // 获取合约地址 + + // 转移一些代币给买家 + await myToken.transfer(buyer.address, parseEther("2.0")); + + // 部署市场合约 + const NFTMarket = await ethers.getContractFactory("NFTMarket"); + nftMarket = await NFTMarket.deploy(); + await nftMarket.waitForDeployment(); + const marketAddress = await nftMarket.getAddress(); // 获取合约地址 + + // 铸造 NFT 给 seller + await myNFT.mint(seller.address, "ipfs://test"); + + // seller 授权市场合约 + await myNFT.connect(seller).setApprovalForAll(marketAddress, true); + + // buyer 授权 ERC20 + await myToken.connect(buyer).approve(marketAddress, PRICE); + }); + + describe("上架商品", function () { + it("应该能够正确上架 NFT", async function () { + const nftAddress = await myNFT.getAddress(); + const tokenAddress = await myToken.getAddress(); + + await nftMarket.connect(seller).listNFT( + nftAddress, + TOKEN_ID, + PRICE, + tokenAddress + ); + + const listing = await nftMarket.listings(nftAddress, TOKEN_ID); + expect(listing.seller).to.equal(seller.address); + expect(listing.price).to.equal(PRICE); + expect(listing.erc20Token).to.equal(tokenAddress); + }); + + it("非 NFT 拥有者不能上架", async function () { + const nftAddress = await myNFT.getAddress(); + const tokenAddress = await myToken.getAddress(); + + await expect( + nftMarket.connect(buyer).listNFT( + nftAddress, + TOKEN_ID, + PRICE, + tokenAddress + ) + ).to.be.reverted; + }); + }); + + describe("购买商品", function () { + beforeEach(async function () { + const nftAddress = await myNFT.getAddress(); + const tokenAddress = await myToken.getAddress(); + + await nftMarket.connect(seller).listNFT( + nftAddress, + TOKEN_ID, + PRICE, + tokenAddress + ); + }); + + it("应该能够正确购买 NFT", async function () { + const nftAddress = await myNFT.getAddress(); + await nftMarket.connect(buyer).buyNFT(nftAddress, TOKEN_ID); + + expect(await myNFT.ownerOf(TOKEN_ID)).to.equal(buyer.address); + + const listing = await nftMarket.listings(nftAddress, TOKEN_ID); + expect(listing.seller).to.equal(ethers.ZeroAddress); + }); + + it("没有足够代币时不能购买", async function () { + const nftAddress = await myNFT.getAddress(); + const poorBuyer = await ethers.provider.getSigner(3); + await expect( + nftMarket.connect(poorBuyer).buyNFT(nftAddress, TOKEN_ID) + ).to.be.reverted; + }); + }); + + describe("下架商品", function () { + beforeEach(async function () { + const nftAddress = await myNFT.getAddress(); + const tokenAddress = await myToken.getAddress(); + + // 先上架一个NFT + await nftMarket.connect(seller).listNFT( + nftAddress, + TOKEN_ID, + PRICE, + tokenAddress + ); + }); + + it("卖家应该能够下架NFT", async function () { + const nftAddress = await myNFT.getAddress(); + + await expect(nftMarket.connect(seller).delistNFT(nftAddress, TOKEN_ID)) + .to.emit(nftMarket, "ItemDelisted") + .withArgs(seller.address, nftAddress, TOKEN_ID); + + // 验证NFT已返还给卖家 + expect(await myNFT.ownerOf(TOKEN_ID)).to.equal(seller.address); + + // 验证listing已被删除 + const listing = await nftMarket.listings(nftAddress, TOKEN_ID); + expect(listing.price).to.equal(0); + }); + + it("非卖家不能下架NFT", async function () { + const nftAddress = await myNFT.getAddress(); + await expect( + nftMarket.connect(buyer).delistNFT(nftAddress, TOKEN_ID) + ).to.be.revertedWith("Not the seller"); + }); + }); + + describe("查询功能", function () { + const TOKEN_ID_2 = 1; + beforeEach(async function () { + const nftAddress = await myNFT.getAddress(); + const tokenAddress = await myToken.getAddress(); + + // 铸造第二个NFT + await myNFT.mint(seller.address, "ipfs://test2"); + + // 上架两个NFT + await nftMarket.connect(seller).listNFT( + nftAddress, + TOKEN_ID, + PRICE, + tokenAddress + ); + await nftMarket.connect(seller).listNFT( + nftAddress, + TOKEN_ID_2, + parseEther("2.0"), + tokenAddress + ); + }); + + it("应该能正确获取上架信息", async function () { + const nftAddress = await myNFT.getAddress(); + const tokenAddress = await myToken.getAddress(); + + const listing = await nftMarket.getListing(nftAddress, TOKEN_ID); + expect(listing.seller).to.equal(seller.address); + expect(listing.price).to.equal(PRICE); + expect(listing.erc20Token).to.equal(tokenAddress); + }); + + it("应该正确判断NFT是否上架", async function () { + const nftAddress = await myNFT.getAddress(); + + expect(await nftMarket.isListed(nftAddress, TOKEN_ID)).to.be.true; + expect(await nftMarket.isListed(nftAddress, 999)).to.be.false; + }); + + it("应该能正确获取所有上架的NFT信息", async function () { + const nftAddress = await myNFT.getAddress(); + const tokenAddress = await myToken.getAddress(); + + const [items, sellers, prices, erc20Tokens] = await nftMarket.getAllListedNFTs(); + + // 验证返回的数组长度 + expect(items.length).to.equal(2); + expect(sellers.length).to.equal(2); + expect(prices.length).to.equal(2); + expect(erc20Tokens.length).to.equal(2); + + // 验证第一个NFT的信息 + expect(items[0].nftContract).to.equal(nftAddress); + expect(items[0].tokenId).to.equal(TOKEN_ID); + expect(sellers[0]).to.equal(seller.address); + expect(prices[0]).to.equal(PRICE); + expect(erc20Tokens[0]).to.equal(tokenAddress); + + // 验证第二个NFT的信息 + expect(items[1].nftContract).to.equal(nftAddress); + expect(items[1].tokenId).to.equal(TOKEN_ID_2); + expect(sellers[1]).to.equal(seller.address); + expect(prices[1]).to.equal(parseEther("2.0")); + expect(erc20Tokens[1]).to.equal(tokenAddress); + }); + + it("应该能正确获取上架NFT的总数", async function () { + expect(await nftMarket.getListedNFTCount()).to.equal(2); + + // 下架一个NFT后,总数应该减少 + const nftAddress = await myNFT.getAddress(); + await nftMarket.connect(seller).delistNFT(nftAddress, TOKEN_ID); + expect(await nftMarket.getListedNFTCount()).to.equal(1); + }); + + it("购买NFT后应该从列表中移除", async function () { + const nftAddress = await myNFT.getAddress(); + await nftMarket.connect(buyer).buyNFT(nftAddress, TOKEN_ID); + + const [items] = await nftMarket.getAllListedNFTs(); + expect(items.length).to.equal(1); + expect(items[0].tokenId).to.equal(TOKEN_ID_2); + }); + }); +}); diff --git a/members/Old000Driver/task3/test/MyNFT.test.js b/members/Old000Driver/task3/test/MyNFT.test.js new file mode 100644 index 000000000..108cfef98 --- /dev/null +++ b/members/Old000Driver/task3/test/MyNFT.test.js @@ -0,0 +1,49 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); + +describe("MyNFT", function () { + let myNFT; + let owner; + let addr1; + let addr2; + + beforeEach(async function () { + [owner, addr1, addr2] = await ethers.getSigners(); + + const MyNFT = await ethers.getContractFactory("MyNFT"); + myNFT = await MyNFT.deploy(); + await myNFT.waitForDeployment(); + }); + + describe("铸造", function () { + it("应该能够铸造 NFT", async function () { + await myNFT.mint(addr1.address, "ipfs://test"); + expect(await myNFT.ownerOf(0)).to.equal(addr1.address); + }); + + it("应该正确递增 tokenId", async function () { + await myNFT.mint(addr1.address, "ipfs://test1"); + await myNFT.mint(addr2.address, "ipfs://test2"); + + expect(await myNFT.ownerOf(0)).to.equal(addr1.address); + expect(await myNFT.ownerOf(1)).to.equal(addr2.address); + }); + }); + + describe("转账", function () { + beforeEach(async function () { + await myNFT.mint(addr1.address, "ipfs://test"); + }); + + it("持有者应该能够转移 NFT", async function () { + await myNFT.connect(addr1).transferFrom(addr1.address, addr2.address, 0); + expect(await myNFT.ownerOf(0)).to.equal(addr2.address); + }); + + it("非持有者不能转移 NFT", async function () { + await expect( + myNFT.connect(addr2).transferFrom(addr1.address, addr2.address, 0) + ).to.be.reverted; + }); + }); +}); diff --git a/members/Old000Driver/task3/test/MyToken.test.js b/members/Old000Driver/task3/test/MyToken.test.js new file mode 100644 index 000000000..a92d8d3f6 --- /dev/null +++ b/members/Old000Driver/task3/test/MyToken.test.js @@ -0,0 +1,58 @@ +const { expect } = require("chai"); +const { ethers } = require("hardhat"); +const { parseEther } = ethers; + +describe("MyToken", function () { + let myToken; + let owner; + let addr1; + let addr2; + const INITIAL_SUPPLY = parseEther("1000000"); + + beforeEach(async function () { + [owner, addr1, addr2] = await ethers.getSigners(); + + const MyToken = await ethers.getContractFactory("MyToken"); + myToken = await MyToken.deploy(INITIAL_SUPPLY); + await myToken.waitForDeployment(); + }); + + describe("基础功能", function () { + it("应该设置正确的初始供应量", async function () { + expect(await myToken.totalSupply()).to.equal(INITIAL_SUPPLY); + expect(await myToken.balanceOf(owner.address)).to.equal(INITIAL_SUPPLY); + }); + + it("应该能够转账代币", async function () { + const transferAmount = parseEther("100"); + await myToken.transfer(addr1.address, transferAmount); + + expect(await myToken.balanceOf(addr1.address)).to.equal(transferAmount); + expect(await myToken.balanceOf(owner.address)).to.equal(INITIAL_SUPPLY - transferAmount); + }); + }); + + describe("授权和委托转账", function () { + const approveAmount = parseEther("100"); + + it("应该能够授权和使用授权", async function () { + await myToken.approve(addr1.address, approveAmount); + await myToken.connect(addr1).transferFrom(owner.address, addr2.address, approveAmount); + + expect(await myToken.balanceOf(addr2.address)).to.equal(approveAmount); + expect(await myToken.allowance(owner.address, addr1.address)).to.equal(0); + }); + + it("不能超额使用授权额度", async function () { + await myToken.approve(addr1.address, approveAmount); + + await expect( + myToken.connect(addr1).transferFrom( + owner.address, + addr2.address, + approveAmount + parseEther("1") + ) + ).to.be.reverted; + }); + }); +}); diff --git a/members/Old000Driver/task4/.gitignore b/members/Old000Driver/task4/.gitignore new file mode 100644 index 000000000..5ef6a5207 --- /dev/null +++ b/members/Old000Driver/task4/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/members/Old000Driver/task4/README.md b/members/Old000Driver/task4/README.md new file mode 100644 index 000000000..e215bc4cc --- /dev/null +++ b/members/Old000Driver/task4/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/members/Old000Driver/task4/app/favicon.ico b/members/Old000Driver/task4/app/favicon.ico new file mode 100644 index 000000000..718d6fea4 Binary files /dev/null and b/members/Old000Driver/task4/app/favicon.ico differ diff --git a/members/Old000Driver/task4/app/globals.css b/members/Old000Driver/task4/app/globals.css new file mode 100644 index 000000000..ac6844236 --- /dev/null +++ b/members/Old000Driver/task4/app/globals.css @@ -0,0 +1,94 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + font-family: Arial, Helvetica, sans-serif; +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + --chart-1: 12 76% 61%; + --chart-2: 173 58% 39%; + --chart-3: 197 37% 24%; + --chart-4: 43 74% 66%; + --chart-5: 27 87% 67%; + --radius: 0.5rem; + --sidebar-background: 0 0% 98%; + --sidebar-foreground: 240 5.3% 26.1%; + --sidebar-primary: 240 5.9% 10%; + --sidebar-primary-foreground: 0 0% 98%; + --sidebar-accent: 240 4.8% 95.9%; + --sidebar-accent-foreground: 240 5.9% 10%; + --sidebar-border: 220 13% 91%; + --sidebar-ring: 217.2 91.2% 59.8%; + } + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; + --chart-1: 220 70% 50%; + --chart-2: 160 60% 45%; + --chart-3: 30 80% 55%; + --chart-4: 280 65% 60%; + --chart-5: 340 75% 55%; + --sidebar-background: 240 5.9% 10%; + --sidebar-foreground: 240 4.8% 95.9%; + --sidebar-primary: 224.3 76.3% 48%; + --sidebar-primary-foreground: 0 0% 100%; + --sidebar-accent: 240 3.7% 15.9%; + --sidebar-accent-foreground: 240 4.8% 95.9%; + --sidebar-border: 240 3.7% 15.9%; + --sidebar-ring: 217.2 91.2% 59.8%; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/members/Old000Driver/task4/app/layout.tsx b/members/Old000Driver/task4/app/layout.tsx new file mode 100644 index 000000000..32a564174 --- /dev/null +++ b/members/Old000Driver/task4/app/layout.tsx @@ -0,0 +1,15 @@ +import './globals.css'; +import '@rainbow-me/rainbowkit/styles.css'; +import { Providers } from './providers'; + +function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} + +export default RootLayout; diff --git a/members/Old000Driver/task4/app/page.tsx b/members/Old000Driver/task4/app/page.tsx new file mode 100644 index 000000000..2da137074 --- /dev/null +++ b/members/Old000Driver/task4/app/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { useState } from "react"; +import { ListNFT } from "../components/list-nft"; +import { NFTGrid } from "../components/nft-grid"; +import { useAccount } from "wagmi"; +import { useEffect } from "react"; +import { ConnectButton } from "@rainbow-me/rainbowkit"; +import NFTMarketplace from "@/components/nft-marketplace"; + +export default function Home() { + // const [isConnected, setIsConnected] = useState(false) + + const { isConnected, address } = useAccount(); + + useEffect(() => { + if (isConnected) { + // 这里可以执行连接成功后的回调 + console.log("Connected with address:", address); + } + }, [isConnected, address]); + + return ( +
+
+
+

NFT Marketplace

+ +
+
+
+ {isConnected && ( + <> + {/*
+

Listed NFTs

+ +
*/} + + + )} + {!isConnected && ( +
+

+ Welcome to NFT Marketplace +

+

+ Please connect your wallet to start trading NFTs. +

+
+ )} +
+
+ ); +} diff --git a/members/Old000Driver/task4/app/providers.tsx b/members/Old000Driver/task4/app/providers.tsx new file mode 100644 index 000000000..0bf437bf6 --- /dev/null +++ b/members/Old000Driver/task4/app/providers.tsx @@ -0,0 +1,34 @@ +"use client"; + +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { WagmiProvider } from "wagmi"; +import { RainbowKitProvider } from "@rainbow-me/rainbowkit"; +import { useEffect, useState } from "react"; + +import { config } from "../wagmi"; + +const queryClient = new QueryClient(); + +// 确保初始渲染匹配服务器端 +// if (!mounted) { +// return
{children}
; +// } + +export function Providers({ children }: { children: React.ReactNode }) { + const [mounted, setMounted] = useState(false); + useEffect(() => { + setMounted(true); + }, []); + return ( + <> + {mounted ? ( + + + {children} + + + ) : null} + + ); +} diff --git a/members/Old000Driver/task4/components.json b/members/Old000Driver/task4/components.json new file mode 100644 index 000000000..d9ef0ae53 --- /dev/null +++ b/members/Old000Driver/task4/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/members/Old000Driver/task4/components/buy-nft.tsx b/members/Old000Driver/task4/components/buy-nft.tsx new file mode 100644 index 000000000..10a8898cf --- /dev/null +++ b/members/Old000Driver/task4/components/buy-nft.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { useWriteContract, useWaitForTransactionReceipt } from "wagmi"; +import { NFTMarketABI } from "@/constants/abi"; +import { toast } from "sonner"; +import { ListingNFT } from "@/utils/fetchListings"; + +interface BuyNFTProps { + nft: ListingNFT; + onBuyNFT: (tokenId: string) => void; +} + +export function BuyNFT({ nft, onBuyNFT }: BuyNFTProps) { + const [open, setOpen] = useState(false); + const [txHash, setTxHash] = useState<`0x${string}` | undefined>(); + const { writeContractAsync } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash: txHash }); + + const handleBuy = async () => { + if (isConfirming) return; + + try { + console.log("提交购买交易..."); + const result = await writeContractAsync({ + address: process.env.NEXT_PUBLIC_MARKET_CONTRACT_ADDRESS as `0x${string}`, + abi: NFTMarketABI, + functionName: "buyNFT", + args: [nft.nftContract, BigInt(nft.tokenId)], + }); + + console.log("writeContractAsync 返回值:", result); + if (typeof result === "string") { + setTxHash(result as `0x${string}`); + } else if (result.hash) { + setTxHash(result.hash); + } else { + throw new Error("无法获取交易哈希"); + } + + toast.success("交易已提交"); + } catch (error) { + console.error("购买失败:", error); + toast.error(error instanceof Error ? error.message : "未知错误"); + } + }; + + // 监听交易确认 + useEffect(() => { + if (isSuccess && txHash) { + toast.success("购买成功"); + onBuyNFT(nft.tokenId); + setTxHash(undefined); + setOpen(false); // 关闭对话框 + } + }, [isSuccess, txHash, onBuyNFT, nft.tokenId]); + + return ( + + + + + + + Buy NFT + +
+

Are you sure you want to buy this NFT?

+

Name: {nft.name}

+

Price: {nft.price} MTK

+

Seller: {nft.seller}

+ +
+
+
+ ); +} diff --git a/members/Old000Driver/task4/components/delist-nft.tsx b/members/Old000Driver/task4/components/delist-nft.tsx new file mode 100644 index 000000000..671410576 --- /dev/null +++ b/members/Old000Driver/task4/components/delist-nft.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { useWriteContract, useWaitForTransactionReceipt } from "wagmi"; +import { NFTMarketABI } from "@/constants/abi"; +import { toast } from "sonner"; +import { ListingNFT } from "@/utils/fetchListings"; + +interface DelistNFTProps { + nft: ListingNFT; + onDelist: (tokenId: string) => void; +} + +export function DelistNFT({ nft, onDelist }: DelistNFTProps) { + const [txHash, setTxHash] = useState<`0x${string}` | undefined>(); + const { writeContractAsync } = useWriteContract(); + const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash: txHash }); + + const handleDelist = async () => { + if (isConfirming) return; + + try { + console.log("提交取消上架交易..."); + const result = await writeContractAsync({ + address: process.env.NEXT_PUBLIC_MARKET_CONTRACT_ADDRESS as `0x${string}`, + abi: NFTMarketABI, + functionName: "delistNFT", + args: [nft.nftContract, BigInt(nft.tokenId)], + }); + + console.log("writeContractAsync 返回值:", result); + if (typeof result === "string") { + setTxHash(result as `0x${string}`); + } else if (result.hash) { + setTxHash(result.hash); + } else { + throw new Error("无法获取交易哈希"); + } + + toast.success("交易已提交"); + } catch (error) { + console.error("取消上架失败:", error); + toast.error(error instanceof Error ? error.message : "未知错误"); + } + }; + + // 监听交易确认 + useEffect(() => { + if (isSuccess && txHash) { + toast.success("取消上架成功"); + onDelist(nft.tokenId); + setTxHash(undefined); + } + }, [isSuccess, txHash, onDelist, nft.tokenId]); + + return ( + + ); +} \ No newline at end of file diff --git a/members/Old000Driver/task4/components/header.tsx b/members/Old000Driver/task4/components/header.tsx new file mode 100644 index 000000000..e7bd66a99 --- /dev/null +++ b/members/Old000Driver/task4/components/header.tsx @@ -0,0 +1,35 @@ +"use client" + +import { useState } from "react" +import { Input } from "@/components/ui/input" +import { Button } from "@/components/ui/button" +import { Search } from "lucide-react" + +export function Header() { + const [searchQuery, setSearchQuery] = useState("") + + const handleSearch = () => { + // In a real application, you would implement the search functionality here + console.log("Searching for:", searchQuery) + } + + return ( +
+
+
+ setSearchQuery(e.target.value)} + /> + +
+
+
+ ) +} + diff --git a/members/Old000Driver/task4/components/list-nft.tsx b/members/Old000Driver/task4/components/list-nft.tsx new file mode 100644 index 000000000..21fdca4a7 --- /dev/null +++ b/members/Old000Driver/task4/components/list-nft.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { useWriteContract, useWaitForTransactionReceipt } from "wagmi"; +import { NFTMarketABI } from "@/constants/abi"; +import { parseEther } from "viem"; +import { toast } from "sonner"; + +// MTK 代币地址 +const MTK_TOKEN_ADDRESS = process.env.NEXT_PUBLIC_MARKET_ERC20_ADDRESS; +const NFT_CONTRACT_ADDRESS = process.env.NEXT_PUBLIC_NFT_CONTRACT_ADDRESS; + +interface ListNFTProps { + onSuccess: () => Promise; +} + +export function ListNFT({ onSuccess }: ListNFTProps) { + const [open, setOpen] = useState(false); + const [tokenId, setTokenId] = useState(""); + const [price, setPrice] = useState(""); + const [isLoading, setIsLoading] = useState(false); + + const { data: hash, writeContract, isPending } = useWriteContract(); + + // 监听交易状态 + const { isLoading: isConfirming, isSuccess: isConfirmed } = + useWaitForTransactionReceipt({ + hash, + }); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + // 将价格转换为wei (18位精度) + const priceInWei = parseEther(price); + + // 调用合约的 listNFT 函数 + console.log( + "🔔", + NFT_CONTRACT_ADDRESS, + tokenId, + priceInWei, + MTK_TOKEN_ADDRESS + ); + await writeContract({ + address: process.env + .NEXT_PUBLIC_MARKET_CONTRACT_ADDRESS as `0x${string}`, + abi: NFTMarketABI, + functionName: "listNFT", + args: [ + NFT_CONTRACT_ADDRESS, + tokenId, + priceInWei, // 使用转换后的价格 + MTK_TOKEN_ADDRESS, // 使用 MTK 代币地址 + ], + }); + + toast.success("NFT listing submitted"); + // 重置表单 + setOpen(false); + setTokenId(""); + setPrice(""); + } catch (error) { + console.error("Error listing NFT:", error); + toast.error("Failed to list NFT. Please check the price format."); + } finally { + setIsLoading(false); + } + }; + + // 交易确认后的处理 + useEffect(() => { + if (isConfirmed) { + toast.success("NFT listed successfully!"); + onSuccess(); // 调用刷新函数 + } + }, [isConfirmed, onSuccess]); + + return ( + + + + + + + List NFT for Sale + +
+
+ + setTokenId(e.target.value)} + placeholder="1" + required + /> +
+
+ + setPrice(e.target.value)} + placeholder="1.0" + type="number" + step="0.01" + required + /> +
+ +
+
+
+ ); +} diff --git a/members/Old000Driver/task4/components/nft-card.tsx b/members/Old000Driver/task4/components/nft-card.tsx new file mode 100644 index 000000000..4652fa181 --- /dev/null +++ b/members/Old000Driver/task4/components/nft-card.tsx @@ -0,0 +1,59 @@ +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import Image from "next/image"; +import { BuyNFT } from "./buy-nft"; +import { ListingNFT } from "@/utils/fetchListings"; +import { DelistNFT } from "./delist-nft"; +import { useAccount } from "wagmi"; + +interface NFTCardProps { + nft: ListingNFT; + onBuyNFT: (id: string) => void; + onDelist: (id: string) => void; +} + +export function NFTCard({ nft, onBuyNFT, onDelist }: NFTCardProps) { + const { address } = useAccount(); + const isOwner = address?.toLowerCase() === nft.seller.toLowerCase(); + + return ( + + + {nft.name} + + +
+ {nft.name} +
+

{nft.name}

+

Price: {nft.price} MTK

+

+ Contract: {nft.nftContract.slice(0, 6)}...{nft.nftContract.slice(-4)} +

+

Token ID: {nft.tokenId}

+

+ Seller: {nft.seller.slice(0, 6)}...{nft.seller.slice(-4)} +

+
+ + {isOwner ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/members/Old000Driver/task4/components/nft-grid.tsx b/members/Old000Driver/task4/components/nft-grid.tsx new file mode 100644 index 000000000..961f06f4b --- /dev/null +++ b/members/Old000Driver/task4/components/nft-grid.tsx @@ -0,0 +1,73 @@ +"use client" + +import { useState } from "react" +import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" + +interface NFT { + id: string + name: string + contractAddress: string + tokenId: string + price: string + seller: string +} + +// This is mock data. In a real application, you would fetch this data from your backend or blockchain +const mockNFTs: NFT[] = [ + { id: "1", name: "Cool Cat #1", contractAddress: "0x123...abc", tokenId: "1", price: "100", seller: "0xabc...123" }, + { + id: "2", + name: "Bored Ape #42", + contractAddress: "0x456...def", + tokenId: "42", + price: "200", + seller: "0xdef...456", + }, + { + id: "3", + name: "Crypto Punk #888", + contractAddress: "0x789...ghi", + tokenId: "888", + price: "150", + seller: "0xghi...789", + }, + { id: "4", name: "Doodle #007", contractAddress: "0xabc...123", tokenId: "7", price: "80", seller: "0x123...abc" }, + { id: "5", name: "Azuki #555", contractAddress: "0xdef...456", tokenId: "555", price: "120", seller: "0x456...def" }, + { + id: "6", + name: "World of Women #303", + contractAddress: "0xghi...789", + tokenId: "303", + price: "90", + seller: "0x789...ghi", + }, +] + +export function NFTGrid() { + const [nfts] = useState(mockNFTs) + + return ( +
+ {nfts.map((nft) => ( + + + {nft.name} + + +

Contract: {nft.contractAddress}

+

Token ID: {nft.tokenId}

+

Price: {nft.price} ERC20

+

Seller: {nft.seller}

+
+ + + +
+ ))} +
+ ) +} + diff --git a/members/Old000Driver/task4/components/nft-marketplace.tsx b/members/Old000Driver/task4/components/nft-marketplace.tsx new file mode 100644 index 000000000..810a923be --- /dev/null +++ b/members/Old000Driver/task4/components/nft-marketplace.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { NFTCard } from "./nft-card"; +import { ListNFT } from "./list-nft"; +import { fetchAllListings, type ListingNFT } from "@/utils/fetchListings"; + +export default function NFTMarketplace() { + const [nftList, setNftList] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const refreshNFTs = useCallback(async () => { + try { + setIsLoading(true); + const listings = await fetchAllListings(); + setNftList(listings); + } catch (err) { + setError(err instanceof Error ? err.message : "获取NFT列表失败"); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + refreshNFTs(); + }, [refreshNFTs]); + + const handleBuyNFT = useCallback( + async (tokenId: string) => { + console.log(`Buying NFT with tokenId: ${tokenId}`); + await refreshNFTs(); // 购买后刷新列表 + }, + [refreshNFTs] + ); + + const handleDelist = useCallback(async (tokenId: string) => { + console.log("Starting refresh after delist for tokenId:", tokenId); + try { + await new Promise(resolve => setTimeout(resolve, 1000)); // 等待1秒确保交易完全确认 + await refreshNFTs(); + console.log("Refresh completed after delist"); + } catch (error) { + console.error("Error refreshing after delist:", error); + toast.error("Failed to refresh NFT list"); + } + }, [refreshNFTs]); + + return ( +
+
+
+ +
+ {isLoading &&

加载中...

} + {error &&

{error}

} +
+ {nftList.map((nft: ListingNFT) => ( + + ))} +
+ {nftList.length === 0 && ( +

+ No NFTs found. Try a different search or list a new NFT. +

+ )} +
+ ); +} diff --git a/members/Old000Driver/task4/components/theme-provider.tsx b/members/Old000Driver/task4/components/theme-provider.tsx new file mode 100644 index 000000000..55c2f6eb6 --- /dev/null +++ b/members/Old000Driver/task4/components/theme-provider.tsx @@ -0,0 +1,11 @@ +'use client' + +import * as React from 'react' +import { + ThemeProvider as NextThemesProvider, + type ThemeProviderProps, +} from 'next-themes' + +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children} +} diff --git a/members/Old000Driver/task4/components/ui/accordion.tsx b/members/Old000Driver/task4/components/ui/accordion.tsx new file mode 100644 index 000000000..24c788c2c --- /dev/null +++ b/members/Old000Driver/task4/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) + +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/members/Old000Driver/task4/components/ui/alert-dialog.tsx b/members/Old000Driver/task4/components/ui/alert-dialog.tsx new file mode 100644 index 000000000..25e7b4744 --- /dev/null +++ b/members/Old000Driver/task4/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/members/Old000Driver/task4/components/ui/alert.tsx b/members/Old000Driver/task4/components/ui/alert.tsx new file mode 100644 index 000000000..41fa7e056 --- /dev/null +++ b/members/Old000Driver/task4/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/members/Old000Driver/task4/components/ui/aspect-ratio.tsx b/members/Old000Driver/task4/components/ui/aspect-ratio.tsx new file mode 100644 index 000000000..d6a5226f5 --- /dev/null +++ b/members/Old000Driver/task4/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/members/Old000Driver/task4/components/ui/avatar.tsx b/members/Old000Driver/task4/components/ui/avatar.tsx new file mode 100644 index 000000000..51e507ba9 --- /dev/null +++ b/members/Old000Driver/task4/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/members/Old000Driver/task4/components/ui/badge.tsx b/members/Old000Driver/task4/components/ui/badge.tsx new file mode 100644 index 000000000..f000e3ef5 --- /dev/null +++ b/members/Old000Driver/task4/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", + secondary: + "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", + destructive: + "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/members/Old000Driver/task4/components/ui/breadcrumb.tsx b/members/Old000Driver/task4/components/ui/breadcrumb.tsx new file mode 100644 index 000000000..60e6c96f7 --- /dev/null +++ b/members/Old000Driver/task4/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>