Initial commit with 🏗️ create-eth @ 2.0.4
This commit is contained in:
114
.cursor/rules/scaffold-eth.mdc
Normal file
114
.cursor/rules/scaffold-eth.mdc
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
---
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
This codebase contains Scaffold-ETH 2 (SE-2), everything you need to build dApps on Ethereum. Its tech stack is NextJS, RainbowKit, Wagmi and Typescript. Supports Hardhat and Foundry.
|
||||||
|
|
||||||
|
It's a yarn monorepo that contains following packages:
|
||||||
|
|
||||||
|
- Hardhat (`packages/hardhat`): The solidity framework to write, test and deploy EVM Smart Contracts.
|
||||||
|
- NextJS (`packages/nextjs`): The UI framework extended with utilities to make interacting with Smart Contracts easy (using Next.js App Router, not Pages Router).
|
||||||
|
|
||||||
|
|
||||||
|
The usual dev flow is:
|
||||||
|
|
||||||
|
- Start SE-2 locally:
|
||||||
|
- `yarn chain`: Starts a local blockchain network
|
||||||
|
- `yarn deploy`: Deploys SE-2 default contract
|
||||||
|
- `yarn start`: Starts the frontend
|
||||||
|
- Write a Smart Contract (modify the deployment script in `packages/hardhat/deploy` if needed)
|
||||||
|
- Deploy it locally (`yarn deploy`)
|
||||||
|
- Go to the `http://locahost:3000/debug` page to interact with your contract with a nice UI
|
||||||
|
- Iterate until you get the functionality you want in your contract
|
||||||
|
- Write tests for the contract in `packages/hardhat/test`
|
||||||
|
- Create your custom UI using all the SE-2 components, hooks, and utilities.
|
||||||
|
- Deploy your Smart Contrac to a live network
|
||||||
|
- Deploy your UI (`yarn vercel` or `yarn ipfs`)
|
||||||
|
- You can tweak which network the frontend is pointing (and some other configurations) in `scaffold.config.ts`
|
||||||
|
|
||||||
|
## Smart Contract UI interactions guidelines
|
||||||
|
SE-2 provides a set of hooks that facilitates contract interactions from the UI. It reads the contract data from `deployedContracts.ts` and `externalContracts.ts`, located in `packages/nextjs/contracts`.
|
||||||
|
|
||||||
|
### Reading data from a contract
|
||||||
|
Use the `useScaffoldReadContract` (`packages/nextjs/hooks/scaffold-eth/useScaffoldReadContract.ts`) hook.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```typescript
|
||||||
|
const { data: someData } = useScaffoldReadContract({
|
||||||
|
contractName: "YourContract",
|
||||||
|
functionName: "functionName",
|
||||||
|
args: [arg1, arg2], // optional
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Writing data to a contract
|
||||||
|
Use the `useScaffoldWriteContract` (`packages/nextjs/hooks/scaffold-eth/useScaffoldWriteContract.ts`) hook.
|
||||||
|
1. Initilize the hook with just the contract name
|
||||||
|
2. Call the `writeContractAsync` function.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```typescript
|
||||||
|
const { writeContractAsync: writeYourContractAsync } = useScaffoldWriteContract(
|
||||||
|
{ contractName: "YourContract" }
|
||||||
|
);
|
||||||
|
// Usage (this will send a write transaction to the contract)
|
||||||
|
await writeContractAsync({
|
||||||
|
functionName: "functionName",
|
||||||
|
args: [arg1, arg2], // optional
|
||||||
|
value: parseEther("0.1"), // optional, for payable functions
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Never use any other patterns for contract interaction. The hooks are:
|
||||||
|
- useScaffoldReadContract (for reading)
|
||||||
|
- useScaffoldWriteContract (for writing)
|
||||||
|
|
||||||
|
### Reading events from a contract
|
||||||
|
|
||||||
|
Use the `useScaffoldEventHistory` (`packages/nextjs/hooks/scaffold-eth/useScaffoldEventHistory.ts`) hook.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const {
|
||||||
|
data: events,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
} = useScaffoldEventHistory({
|
||||||
|
contractName: "YourContract",
|
||||||
|
eventName: "GreetingChange",
|
||||||
|
watch: true, // optional, if true, the hook will watch for new events
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The `data` property consists of an array of events and can be displayed as:
|
||||||
|
|
||||||
|
```jsx
|
||||||
|
<div>
|
||||||
|
{events?.map((event) => (
|
||||||
|
<div key={event.logIndex}>
|
||||||
|
<p>{event.args.greetingSetter}</p>
|
||||||
|
<p>{event.args.newGreeting}</p>
|
||||||
|
<p>{event.args.premium}</p>
|
||||||
|
<p>{event.args.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Other Hooks
|
||||||
|
SE-2 also provides other hooks to interact with blockchain data: `useScaffoldWatchContractEvent`, `useScaffoldEventHistory`, `useDeployedContractInfo`, `useScaffoldContract`, `useTransactor`. They live under `packages/nextjs/hooks/scaffold-eth`.
|
||||||
|
|
||||||
|
## Display Components guidelines
|
||||||
|
With the `@scaffold-ui/components` library, SE-2 provides a set of pre-built React components for common Ethereum use cases:
|
||||||
|
|
||||||
|
- `Address`: Always use this when displaying an ETH address
|
||||||
|
- `AddressInput`: Always use this when users need to input an ETH address
|
||||||
|
- `Balance`: Display the ETH/USDC balance of a given address
|
||||||
|
- `EtherInput`: An extended number input with ETH/USD conversion.
|
||||||
|
|
||||||
|
For fully customizable components, you can use the hooks from the `@scaffold-ui/hooks` library to get the data you need.
|
||||||
|
|
||||||
|
Find the relevant information from the documentation and the codebase. Think step by step before answering the question.
|
||||||
1
.cursorignore
Normal file
1
.cursorignore
Normal file
@@ -0,0 +1 @@
|
|||||||
|
*
|
||||||
43
.github/workflows/lint.yaml
vendored
Normal file
43
.github/workflows/lint.yaml
vendored
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
name: Lint
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu-latest]
|
||||||
|
node: [lts/*]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@master
|
||||||
|
|
||||||
|
- name: Setup node env
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node }}
|
||||||
|
cache: yarn
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install --immutable
|
||||||
|
|
||||||
|
- name: Run hardhat node, deploy contracts (& generate contracts typescript output)
|
||||||
|
run: yarn chain & yarn deploy
|
||||||
|
|
||||||
|
- name: Run hardhat lint
|
||||||
|
run: yarn hardhat:lint --max-warnings=0
|
||||||
|
|
||||||
|
- name: Run nextjs lint
|
||||||
|
run: yarn next:lint --max-warnings=0
|
||||||
|
|
||||||
|
- name: Check typings on nextjs
|
||||||
|
run: yarn next:check-types
|
||||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/patches
|
||||||
|
!.yarn/plugins
|
||||||
|
!.yarn/releases
|
||||||
|
!.yarn/sdks
|
||||||
|
!.yarn/versions
|
||||||
|
|
||||||
|
# eslint
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# cli
|
||||||
|
dist
|
||||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
|||||||
|
yarn lint-staged --verbose
|
||||||
21
.lintstagedrc.js
Normal file
21
.lintstagedrc.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const buildNextEslintCommand = (filenames) =>
|
||||||
|
`yarn next:lint --fix --file ${filenames
|
||||||
|
.map((f) => path.relative(path.join("packages", "nextjs"), f))
|
||||||
|
.join(" --file ")}`;
|
||||||
|
|
||||||
|
const checkTypesNextCommand = () => "yarn next:check-types";
|
||||||
|
|
||||||
|
const buildHardhatEslintCommand = (filenames) =>
|
||||||
|
`yarn hardhat:lint-staged --fix ${filenames
|
||||||
|
.map((f) => path.relative(path.join("packages", "hardhat"), f))
|
||||||
|
.join(" ")}`;
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
"packages/nextjs/**/*.{ts,tsx}": [
|
||||||
|
buildNextEslintCommand,
|
||||||
|
checkTypesNextCommand,
|
||||||
|
],
|
||||||
|
"packages/hardhat/**/*.{ts,tsx}": [buildHardhatEslintCommand],
|
||||||
|
};
|
||||||
541
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
541
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
9
.yarn/plugins/@yarnpkg/plugin-typescript.cjs
vendored
Normal file
9
.yarn/plugins/@yarnpkg/plugin-typescript.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
783
.yarn/releases/yarn-3.2.3.cjs
vendored
Executable file
783
.yarn/releases/yarn-3.2.3.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
13
.yarnrc.yml
Normal file
13
.yarnrc.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
enableColors: true
|
||||||
|
|
||||||
|
nmHoistingLimits: workspaces
|
||||||
|
|
||||||
|
nodeLinker: node-modules
|
||||||
|
|
||||||
|
plugins:
|
||||||
|
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
|
||||||
|
spec: "@yarnpkg/plugin-typescript"
|
||||||
|
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||||
|
spec: "@yarnpkg/plugin-interactive-tools"
|
||||||
|
|
||||||
|
yarnPath: .yarn/releases/yarn-3.2.3.cjs
|
||||||
86
CONTRIBUTING.md
Normal file
86
CONTRIBUTING.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Welcome to Scaffold-ETH 2 Contributing Guide
|
||||||
|
|
||||||
|
Thank you for investing your time in contributing to Scaffold-ETH 2!
|
||||||
|
|
||||||
|
This guide aims to provide an overview of the contribution workflow to help us make the contribution process effective for everyone involved.
|
||||||
|
|
||||||
|
## About the Project
|
||||||
|
|
||||||
|
Scaffold-ETH 2 is a minimal and forkable repo providing builders with a starter kit to build decentralized applications on Ethereum.
|
||||||
|
|
||||||
|
Read the [README](README.md) to get an overview of the project.
|
||||||
|
|
||||||
|
### Vision
|
||||||
|
|
||||||
|
The goal of Scaffold-ETH 2 is to provide the primary building blocks for a decentralized application.
|
||||||
|
|
||||||
|
The repo can be forked to include integrations and more features, but we want to keep the master branch simple and minimal.
|
||||||
|
|
||||||
|
### Project Status
|
||||||
|
|
||||||
|
The project is under active development.
|
||||||
|
|
||||||
|
You can view the open Issues, follow the development process and contribute to the project.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
|
||||||
|
You can contribute to this repo in many ways:
|
||||||
|
|
||||||
|
- Solve open issues
|
||||||
|
- Report bugs or feature requests
|
||||||
|
- Improve the documentation
|
||||||
|
|
||||||
|
Contributions are made via Issues and Pull Requests (PRs). A few general guidelines for contributions:
|
||||||
|
|
||||||
|
- Search for existing Issues and PRs before creating your own.
|
||||||
|
- Contributions should only fix/add the functionality in the issue OR address style issues, not both.
|
||||||
|
- If you're running into an error, please give context. Explain what you're trying to do and how to reproduce the error.
|
||||||
|
- Please use the same formatting in the code repository. You can configure your IDE to do it by using the prettier / linting config files included in each package.
|
||||||
|
- If applicable, please edit the README.md file to reflect the changes.
|
||||||
|
|
||||||
|
### Issues
|
||||||
|
|
||||||
|
Issues should be used to report problems, request a new feature, or discuss potential changes before a PR is created.
|
||||||
|
|
||||||
|
#### Solve an issue
|
||||||
|
|
||||||
|
Scan through our [existing issues](https://github.com/scaffold-eth/scaffold-eth-2/issues) to find one that interests you.
|
||||||
|
|
||||||
|
If a contributor is working on the issue, they will be assigned to the individual. If you find an issue to work on, you are welcome to assign it to yourself and open a PR with a fix for it.
|
||||||
|
|
||||||
|
#### Create a new issue
|
||||||
|
|
||||||
|
If a related issue doesn't exist, you can open a new issue.
|
||||||
|
|
||||||
|
Some tips to follow when you are creating an issue:
|
||||||
|
|
||||||
|
- Provide as much context as possible. Over-communicate to give the most details to the reader.
|
||||||
|
- Include the steps to reproduce the issue or the reason for adding the feature.
|
||||||
|
- Screenshots, videos etc., are highly appreciated.
|
||||||
|
|
||||||
|
### Pull Requests
|
||||||
|
|
||||||
|
#### Pull Request Process
|
||||||
|
|
||||||
|
We follow the ["fork-and-pull" Git workflow](https://github.com/susam/gitpr)
|
||||||
|
|
||||||
|
1. Fork the repo
|
||||||
|
2. Clone the project
|
||||||
|
3. Create a new branch with a descriptive name
|
||||||
|
4. Commit your changes to the new branch
|
||||||
|
5. Push changes to your fork
|
||||||
|
6. Open a PR in our repository and tag one of the maintainers to review your PR
|
||||||
|
|
||||||
|
Here are some tips for a high-quality pull request:
|
||||||
|
|
||||||
|
- Create a title for the PR that accurately defines the work done.
|
||||||
|
- Structure the description neatly to make it easy to consume by the readers. For example, you can include bullet points and screenshots instead of having one large paragraph.
|
||||||
|
- Add the link to the issue if applicable.
|
||||||
|
- Have a good commit message that summarises the work done.
|
||||||
|
|
||||||
|
Once you submit your PR:
|
||||||
|
|
||||||
|
- We may ask questions, request additional information or ask for changes to be made before a PR can be merged. Please note that these are to make the PR clear for everyone involved and aims to create a frictionless interaction process.
|
||||||
|
- As you update your PR and apply changes, mark each conversation resolved.
|
||||||
|
|
||||||
|
Once the PR is approved, we'll "squash-and-merge" to keep the git commit history clean.
|
||||||
21
LICENCE
Normal file
21
LICENCE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2023 BuidlGuidl
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
513
README.md
Normal file
513
README.md
Normal file
@@ -0,0 +1,513 @@
|
|||||||
|
# 🏗 Scaffold-ETH 2
|
||||||
|
|
||||||
|
<h4 align="center">
|
||||||
|
<a href="https://docs.scaffoldeth.io">Documentation</a> |
|
||||||
|
<a href="https://scaffoldeth.io">Website</a>
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
🧪 An open-source, up-to-date toolkit for building decentralized applications (dapps) on the Ethereum blockchain. It's designed to make it easier for developers to create and deploy smart contracts and build user interfaces that interact with those contracts.
|
||||||
|
|
||||||
|
⚙️ Built using NextJS, RainbowKit, Hardhat, Wagmi, Viem, and Typescript.
|
||||||
|
|
||||||
|
- ✅ **Contract Hot Reload**: Your frontend auto-adapts to your smart contract as you edit it.
|
||||||
|
- 🪝 **[Custom hooks](https://docs.scaffoldeth.io/hooks/)**: Collection of React hooks wrapper around [wagmi](https://wagmi.sh/) to simplify interactions with smart contracts with typescript autocompletion.
|
||||||
|
- 🧱 [**Components**](https://docs.scaffoldeth.io/components/): Collection of common web3 components to quickly build your frontend.
|
||||||
|
- 🔥 **Burner Wallet & Local Faucet**: Quickly test your application with a burner wallet and local faucet.
|
||||||
|
- 🔐 **Integration with Wallet Providers**: Connect to different wallet providers and interact with the Ethereum network.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
Before you begin, you need to install the following tools:
|
||||||
|
|
||||||
|
- [Node (>= v20.18.3)](https://nodejs.org/en/download/)
|
||||||
|
- Yarn ([v1](https://classic.yarnpkg.com/en/docs/install/) or [v2+](https://yarnpkg.com/getting-started/install))
|
||||||
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
|
# 🚩 Challenge: 🏵 Token Vendor 🤖
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
🤖 Smart contracts are kind of like "always on" _vending machines_ that **anyone** can access. Let's make a decentralized, digital currency. Then, let's build an unstoppable vending machine that will buy and sell the currency. We'll learn about the "approve" pattern for ERC20s and how contract to contract interactions work.
|
||||||
|
|
||||||
|
🏵 Create `YourToken.sol` smart contract that inherits the **ERC20** token standard from OpenZeppelin. Set your token to `_mint()` **1000** (* 10 ** 18) tokens to the `msg.sender`. Then create a `Vendor.sol` contract that sells your token using a payable `buyTokens()` function.
|
||||||
|
|
||||||
|
🎛 Edit the frontend that invites the user to input an amount of tokens they want to buy. We'll display a preview of the amount of ETH it will cost with a confirm button.
|
||||||
|
|
||||||
|
🔍 It will be important to verify your token's source code in the block explorer after you deploy. Supporters will want to be sure that it has a fixed supply and you can't just mint more.
|
||||||
|
|
||||||
|
🌟 The final deliverable is an app that lets users purchase your ERC20 token, transfer it, and sell it back to the vendor. Deploy your contracts on your public chain of choice and then `yarn vercel` your app to a public web server. Submit the url on [SpeedrunEthereum.com](https://speedrunethereum.com)!
|
||||||
|
|
||||||
|
> 💬 Meet other builders working on this challenge and get help in the [Challenge Telegram](https://t.me/joinchat/IfARhZFc5bfPwpjq)!
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkpoint 0: 📦 Environment 📚
|
||||||
|
|
||||||
|
> Start your local network (a blockchain emulator in your computer):
|
||||||
|
|
||||||
|
```
|
||||||
|
yarn chain
|
||||||
|
```
|
||||||
|
|
||||||
|
> in a second terminal window, 🛰 deploy your contract (locally):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn deploy
|
||||||
|
```
|
||||||
|
|
||||||
|
> in a third terminal window, start your 📱 frontend:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
yarn start
|
||||||
|
```
|
||||||
|
|
||||||
|
📱 Open http://localhost:3000 to see the app.
|
||||||
|
|
||||||
|
> 👩💻 Rerun `yarn deploy --reset` whenever you want to deploy new contracts to the frontend, update your current contracts with changes, or re-deploy it to get a fresh contract address.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
⚠️ We have disabled AI in Cursor and VSCode and highly suggest that you do not enable it so you can focus on the challenge, do everything by yourself, and hence better understand and remember things. If you are using another IDE, please disable AI yourself.
|
||||||
|
|
||||||
|
🔧 If you are a vibe-coder and don't care about understanding the syntax of the code used and just want to understand the general takeaways, you can re-enable AI by:
|
||||||
|
- Cursor: remove `*` from `.cursorignore` file
|
||||||
|
- VSCode: set `chat.disableAIFeatures` to `false` in `.vscode/settings.json` file
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkpoint 1: 🏵Your Token 💵
|
||||||
|
|
||||||
|
> 👩💻 Go to `packages/hardhat/contracts/YourToken.sol` look at how this contract is inheriting the **ERC20** token standard from OpenZeppelin. This means that the `YourToken` contract obtains every method that is a part of the **ERC20** standard and so it has all the default properties needed to be a used as a token on Ethereum.
|
||||||
|
|
||||||
|
> In the `constructor()`, mint a fixed supply of **1000** tokens (with 18 decimals) to `msg.sender` (the deployer).
|
||||||
|
|
||||||
|
### Implementing `YourToken`
|
||||||
|
|
||||||
|
- Decide on the token name/symbol (already scaffolded for you as "Gold"/"GLD").
|
||||||
|
- The OpenZeppelin ERC20 contract you are inheriting exposes a `_mint` function you can use to create new tokens.
|
||||||
|
- Update the constructor to mint **exactly** 1000 tokens to `msg.sender` (the deployer).
|
||||||
|
|
||||||
|
<details markdown='1'>
|
||||||
|
<summary>🔎 Hint</summary>
|
||||||
|
|
||||||
|
ERC20 tokens typically use 18 decimals. Solidity has a convenient unit that matches that scaling:
|
||||||
|
|
||||||
|
- `1000 ether` is `1000 * 10**18`
|
||||||
|
|
||||||
|
So minting 1000 tokens can be as simple as:
|
||||||
|
|
||||||
|
- `_mint(msg.sender, 1000 ether);`
|
||||||
|
|
||||||
|
<details markdown='1'>
|
||||||
|
|
||||||
|
<summary>🎯 Solution</summary>
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
constructor() ERC20("Gold", "GLD") {
|
||||||
|
_mint(msg.sender, 1000 ether);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### 🥅 Goals
|
||||||
|
|
||||||
|
- ⚠️ **Important:** Your initial token supply was minted to the **deployer**. If the wallet you use in the frontend is a different address, you won’t see a balance there yet.
|
||||||
|
- Update `FRONTEND_ADDRESS` in `packages/hardhat/deploy/01_deploy_vendor.ts` and keep `SEND_TOKENS_TO_VENDOR = false` since we are not ready for that step.
|
||||||
|
- Then run `yarn deploy --reset` to send the tokens to your frontend wallet so you can test in the UI.
|
||||||
|
|
||||||
|
- [ ] Can you check the `balanceOf()` your frontend address in the `Debug Contracts` tab? (`YourToken` contract)
|
||||||
|
- [ ] Can you `transfer()` your token to another account and check _that_ account's `balanceOf`?
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
> 💬 Hint: Use an incognito window to create a new address and try sending to that new address. Can use the `transfer()` function in the `Debug Contracts` tab.
|
||||||
|
|
||||||
|
### Testing your progress
|
||||||
|
|
||||||
|
🔍 Run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
yarn test --grep "Checkpoint1"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkpoint 2: ⚖️ Vendor 🤖
|
||||||
|
|
||||||
|
> 👩💻 Edit `packages/hardhat/contracts/Vendor.sol` and build a token vending machine with a **payable** `buyTokens()` function.
|
||||||
|
|
||||||
|
### Step 1: Add a price constant
|
||||||
|
|
||||||
|
Use a price variable named `tokensPerEth` set to **100** (meaning **100 tokens per 1 ETH**):
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
uint256 public constant tokensPerEth = 100;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: Add custom errors
|
||||||
|
|
||||||
|
Instead of `require(condition, "message")`, we’ll use **custom errors** (they’re cheaper at runtime and easier to test).
|
||||||
|
|
||||||
|
In the error section of the contract, add these errors for the common failure cases:
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
error InvalidEthAmount();
|
||||||
|
error InsufficientVendorTokenBalance(uint256 available, uint256 required);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Add an event
|
||||||
|
|
||||||
|
In the event section of the contract, add an event that the UI (and block explorers) can use to track purchases:
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
event BuyTokens(address indexed buyer, uint256 amountOfETH, uint256 amountOfTokens);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementing `buyTokens()`
|
||||||
|
|
||||||
|
The `buyTokens()` function should:
|
||||||
|
|
||||||
|
- Reject a purchase with **0 ETH**, reverting with `InvalidEthAmount`
|
||||||
|
- Compute how many tokens the buyer should receive using `msg.value` and `tokensPerEth`
|
||||||
|
- Make sure the Vendor has enough tokens to sell and if they don't revert with `InsufficientVendorTokenBalance`
|
||||||
|
- Transfer tokens to the buyer
|
||||||
|
- Emit the `BuyTokens` event
|
||||||
|
|
||||||
|
<details markdown='1'>
|
||||||
|
<summary>🔎 Hint</summary>
|
||||||
|
|
||||||
|
**Decimals gotcha (important):**
|
||||||
|
|
||||||
|
- ETH is measured in wei (18 decimals)
|
||||||
|
- ERC20 tokens in this challenge also use 18 decimals
|
||||||
|
|
||||||
|
If `tokensPerEth = 100`, then:
|
||||||
|
|
||||||
|
- Sending `1 ether` should yield `100 ether` token units
|
||||||
|
|
||||||
|
A simple formula that works with 18-decimals tokens:
|
||||||
|
|
||||||
|
- `tokensToBuy = msg.value * tokensPerEth`
|
||||||
|
|
||||||
|
Also, you can check how many tokens the vendor holds with:
|
||||||
|
|
||||||
|
- `yourToken.balanceOf(address(this))`
|
||||||
|
|
||||||
|
<details markdown='1'>
|
||||||
|
|
||||||
|
<summary>🎯 Solution</summary>
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
function buyTokens() external payable {
|
||||||
|
if (msg.value == 0) revert InvalidEthAmount();
|
||||||
|
|
||||||
|
uint256 amountOfTokens = msg.value * tokensPerEth;
|
||||||
|
uint256 vendorBalance = yourToken.balanceOf(address(this));
|
||||||
|
if (vendorBalance < amountOfTokens) revert InsufficientVendorTokenBalance(vendorBalance, amountOfTokens);
|
||||||
|
|
||||||
|
yourToken.transfer(msg.sender, amountOfTokens);
|
||||||
|
emit BuyTokens(msg.sender, msg.value, amountOfTokens);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Try it out (frontend + deploy)
|
||||||
|
|
||||||
|
Edit `packages/hardhat/deploy/01_deploy_vendor.ts` to set `SEND_TOKENS_TO_VENDOR` to `true`. This will deploy the Vendor contract and automatically seed it with the tokens INSTEAD of sending the tokens to your `FRONTEND_ADDRESS`. It will also set your address as the owner of the Vendor contract but we will dig into that later...
|
||||||
|
|
||||||
|
> 🔎 Look in `packages/nextjs/app/token-vendor/page.tsx` and uncomment the `Vendor Balances` and `Buy Tokens` sections to display the Vendor ETH and Token balances as well as enable buying tokens from the frontend.
|
||||||
|
|
||||||
|
> You can `yarn deploy --reset` to deploy your contract until you get it right.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 🥅 Goals
|
||||||
|
|
||||||
|
- [ ] Does the `Vendor` address start with a `balanceOf` **1000** in `YourToken` on the `Debug Contracts` tab?
|
||||||
|
- [ ] Can you buy **10** tokens for **0.1** ETH?
|
||||||
|
- [ ] Can you transfer tokens to a different account?
|
||||||
|
|
||||||
|
### Testing your progress
|
||||||
|
|
||||||
|
🔍 Run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
yarn test --grep "Checkpoint2"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkpoint 3: 👑 Ownable + Withdraw 💸
|
||||||
|
|
||||||
|
Now that your Vendor can accept ETH via `buyTokens()`, let’s protect the treasury.
|
||||||
|
|
||||||
|
### Step 1: Inheriting `Ownable` (OpenZeppelin v5)
|
||||||
|
|
||||||
|
The OpenZeppelin Ownable contract adds special methods, modifiers and state variable for helping to secure certain methods. Notably the `onlyOwner` modifier can be used to guard a method so that only the `owner` can call it.
|
||||||
|
|
||||||
|
- Notice how the vendor contract already imports `Ownable` from OpenZeppelin
|
||||||
|
- See how it is inherited in the line defining the contract:
|
||||||
|
```solidity
|
||||||
|
contract Vendor is Ownable ...
|
||||||
|
```
|
||||||
|
- Lastly see how we define ownership in the constructor with `Ownable(msg.sender)`, making the deployer of the contract the `owner`
|
||||||
|
|
||||||
|
We are using the `onlyOwner` modifier to protect the `withdraw` method so that only the owner can withdraw the contract's ETH balance.
|
||||||
|
|
||||||
|
### Step 2: Add a custom error for failed transfers
|
||||||
|
|
||||||
|
First add an error you will need in the method.
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
error EthTransferFailed(address to, uint256 amount);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementing `withdraw()`
|
||||||
|
|
||||||
|
Your `withdraw()` function should:
|
||||||
|
|
||||||
|
- Be restricted to the owner (`onlyOwner`)
|
||||||
|
- Send **all ETH** in the Vendor to the owner
|
||||||
|
- Revert with `EthTransferFailed` if the ETH transfer fails
|
||||||
|
|
||||||
|
<details markdown='1'>
|
||||||
|
<summary>🔎 Hint</summary>
|
||||||
|
|
||||||
|
Avoid `transfer()` (it can unexpectedly fail due to gas changes and is no longer supported in the latest Solidity versions). Prefer `call`:
|
||||||
|
|
||||||
|
- `(bool ok,) = owner().call{value: amount}("");`
|
||||||
|
|
||||||
|
If `ok` is false, revert.
|
||||||
|
|
||||||
|
<details markdown='1'>
|
||||||
|
|
||||||
|
<summary>🎯 Solution</summary>
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
function withdraw() external onlyOwner {
|
||||||
|
uint256 amount = address(this).balance;
|
||||||
|
(bool success,) = owner().call{value: amount}("");
|
||||||
|
if (!success) revert EthTransferFailed(owner(), amount);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Try it out
|
||||||
|
|
||||||
|
Deploy the updated contract with `yarn deploy --reset` and then go test it out by depositing ETH and withdrawing. You can do this from the `Debug Contracts` tab.
|
||||||
|
|
||||||
|
### 🥅 Goals
|
||||||
|
|
||||||
|
- [ ] Is your frontend address the `owner` of the `Vendor`?
|
||||||
|
- [ ] Can your address successfully withdraw all the ETH in the `Vendor`?
|
||||||
|
|
||||||
|
### Testing your progress
|
||||||
|
|
||||||
|
🔍 Run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
yarn test --grep "Checkpoint3"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkpoint 4: 🤔 Vendor Buyback 🤯
|
||||||
|
|
||||||
|
👩🏫 The hardest part of this challenge is to build your `Vendor` in such a way so that it can buy the tokens back.
|
||||||
|
|
||||||
|
🧐 The reason why this is hard is the `approve()` pattern in ERC20s.
|
||||||
|
|
||||||
|
😕 First, the user has to call `approve()` on the `YourToken` contract, approving the `Vendor` contract address to take some amount of tokens.
|
||||||
|
|
||||||
|
🤨 Then, the user makes a _second transaction_ to the `Vendor` contract to `sellTokens(uint256 amount)`.
|
||||||
|
|
||||||
|
🤓 The `Vendor` should call `yourToken.transferFrom(msg.sender, address(this), theAmount)` and if the user has approved the `Vendor` correctly, tokens should transfer to the `Vendor` and ETH should be sent to the user.
|
||||||
|
|
||||||
|
<details markdown='1'>
|
||||||
|
<summary>🤔 But why do we need the <code>approve</code> method?</summary>
|
||||||
|
|
||||||
|
The crux of the issue is this: if smart contracts can move tokens out of your wallet, how do you make sure that only the smart contract you *want* to take tokens is the one that’s allowed to do it?
|
||||||
|
|
||||||
|
Here’s the simple mental model:
|
||||||
|
|
||||||
|
- **`approve(spender, amount)` = “I allow this contract to spend up to X of my tokens.”**
|
||||||
|
- It does **not** move tokens.
|
||||||
|
- It writes an **allowance** into the token contract: `allowance[you][spender] = amount`.
|
||||||
|
|
||||||
|
- **`transferFrom(from, to, amount)` = “Use that permission to pull tokens.”**
|
||||||
|
- The `Vendor` contract calls this during `sellTokens(...)`.
|
||||||
|
- The token contract checks the allowance and only lets the transfer happen if it’s big enough.
|
||||||
|
|
||||||
|
What this unlocks: **safe, pull-based token interactions** where a contract can perform an action (swap, buyback, marketplace purchase, subscription, etc.) and pull exactly the tokens it needs, **without having blanket access to your wallet**. You can also **limit risk** by approving only the exact amount (or revoke later by approving `0`).
|
||||||
|
|
||||||
|
Luckily, wallet UX is improving fast. With proposals like **EIP-7702** now being enabled on Ethereum, a wallet can let you sign **one** “sell” action that executes a small bundle of steps atomically (e.g. `approve` + `sellTokens` / `transferFrom`) in a single transaction, instead of making you click through two separate user actions. The underlying ERC-20 allowance model still exists; you’re just authorizing a smarter, batched execution path. This only needs to be adopted by wallets and frontends for users to reap the benefits.
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
|
### Step 1: Add custom errors + event
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
error InvalidTokenAmount();
|
||||||
|
error InsufficientVendorEthBalance(uint256 available, uint256 required);
|
||||||
|
|
||||||
|
event SellTokens(address indexed seller, uint256 amountOfTokens, uint256 amountOfETH);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementing `sellTokens(uint256 amount)`
|
||||||
|
|
||||||
|
Your `sellTokens(amount)` should:
|
||||||
|
|
||||||
|
- Reject `amount == 0` with `InvalidTokenAmount`
|
||||||
|
- Pull tokens from the user with `transferFrom` (requires prior `approve` call by user)
|
||||||
|
- Compute the ETH to return using the inverse of your pricing
|
||||||
|
- Ensure the Vendor has enough ETH liquidity and if not, revert with `InsufficientVendorEthBalance`
|
||||||
|
- Send ETH back to the user
|
||||||
|
- Emit a `SellTokens` event
|
||||||
|
|
||||||
|
<details markdown='1'>
|
||||||
|
<summary>🔎 Hint</summary>
|
||||||
|
|
||||||
|
If `tokensPerEth = 100`, then the inverse conversion is:
|
||||||
|
|
||||||
|
- `ethToReturn = amount / tokensPerEth`
|
||||||
|
|
||||||
|
<details markdown='1'>
|
||||||
|
|
||||||
|
<summary>🎯 Solution</summary>
|
||||||
|
|
||||||
|
```solidity
|
||||||
|
function sellTokens(uint256 amount) external {
|
||||||
|
if (amount == 0) revert InvalidTokenAmount();
|
||||||
|
|
||||||
|
uint256 amountOfETH = amount / tokensPerEth;
|
||||||
|
uint256 vendorEthBalance = address(this).balance;
|
||||||
|
if (vendorEthBalance < amountOfETH) revert InsufficientVendorEthBalance(vendorEthBalance, amountOfETH);
|
||||||
|
|
||||||
|
yourToken.transferFrom(msg.sender, address(this), amount);
|
||||||
|
|
||||||
|
(bool success,) = msg.sender.call{value: amountOfETH}("");
|
||||||
|
if (!success) revert EthTransferFailed(msg.sender, amountOfETH);
|
||||||
|
|
||||||
|
emit SellTokens(msg.sender, amount, amountOfETH);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Try it out
|
||||||
|
|
||||||
|
🔁 Redeploy (`yarn deploy --reset`) and try out your new function!
|
||||||
|
|
||||||
|
🔨 Use the `Debug Contracts` tab to call the approve and sellTokens() at first but then...
|
||||||
|
|
||||||
|
🔍 Look in the `packages/nextjs/app/token-vendor/page.tsx` for the extra approve/sell UI to uncomment and then go to `packages/nextjs/app/events/page.tsx` and uncomment the `SellTokens Events` section to update the `Events` tab on the frontend.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### 🥅 Goal
|
||||||
|
|
||||||
|
- [ ] Can you sell tokens back to the vendor?
|
||||||
|
- [ ] Do you receive the right amount of ETH for the tokens?
|
||||||
|
- [ ] Do you see `SellTokens` events in the `Events` tab now?
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
### ⚔️ Side Quests
|
||||||
|
|
||||||
|
- [ ] Should we disable the `owner` withdraw to keep liquidity in the `Vendor`?
|
||||||
|
- [ ] Would people be more interested in your token if they knew there wasn't a way to drain the ETH backing?
|
||||||
|
|
||||||
|
### Testing your progress
|
||||||
|
|
||||||
|
🔍 Run:
|
||||||
|
|
||||||
|
```shell
|
||||||
|
yarn test --grep "Checkpoint4"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkpoint 5: 💾 Deploy your contracts! 🛰
|
||||||
|
|
||||||
|
📡 Edit the `defaultNetwork` in `hardhat.config.ts` to match the name of one of testnets from the `networks` object. We recommend to use `"sepolia"` or `"optimismSepolia"`
|
||||||
|
|
||||||
|
🔐 You will need to generate a **deployer address** using `yarn generate` This creates a mnemonic and saves it locally.
|
||||||
|
|
||||||
|
👩🚀 Use `yarn account` to view your deployer account balances.
|
||||||
|
|
||||||
|
⛽️ You will need to send ETH to your deployer address with your wallet, or get it from a public faucet of your chosen network. You can also request ETH by sending a message with your new deployer address and preferred network in the [challenge Telegram](https://t.me/joinchat/IfARhZFc5bfPwpjq). People are usually more than willing to share.
|
||||||
|
|
||||||
|
🚀 Run `yarn deploy` to deploy your smart contract to a public network (selected in `hardhat.config.ts`)
|
||||||
|
|
||||||
|
> 💬 Hint: Instead of editing `hardhat.config.ts` you can just add a network flag to the deploy command like this: `yarn deploy --network sepolia` or `yarn deploy --network optimismSepolia`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkpoint 6: 🚢 Ship your frontend! 🚁
|
||||||
|
|
||||||
|
✏️ Edit your frontend config in `packages/nextjs/scaffold.config.ts` to change the `targetNetwork` to `chains.sepolia` (or `chains.optimismSepolia` if you deployed to OP Sepolia)
|
||||||
|
|
||||||
|
💻 View your frontend at http://localhost:3000 and verify you see the correct network.
|
||||||
|
|
||||||
|
📡 When you are ready to ship the frontend app...
|
||||||
|
|
||||||
|
📦 Run `yarn vercel` to package up your frontend and deploy.
|
||||||
|
|
||||||
|
> You might need to log in to Vercel first by running `yarn vercel:login`. Once you log in (email, GitHub, etc), the default options should work.
|
||||||
|
|
||||||
|
> If you want to redeploy to the same production URL you can run `yarn vercel --prod`. If you omit the `--prod` flag it will deploy it to a preview/test URL.
|
||||||
|
|
||||||
|
> Follow the steps to deploy to Vercel. It'll give you a public URL.
|
||||||
|
|
||||||
|
> 🦊 Since we have deployed to a public testnet, you will now need to connect using a wallet you own or use a burner wallet. By default 🔥 `burner wallets` are only available on `hardhat` . You can enable them on every chain by setting `onlyLocalBurnerWallet: false` in your frontend config (`scaffold.config.ts` in `packages/nextjs/`)
|
||||||
|
|
||||||
|
#### Configuration of Third-Party Services for Production-Grade Apps.
|
||||||
|
|
||||||
|
By default, 🏗 Scaffold-ETH 2 provides predefined API keys for popular services such as Alchemy and Etherscan. This allows you to begin developing and testing your applications more easily, avoiding the need to register for these services.
|
||||||
|
This is great to complete your **Speedrun Ethereum**.
|
||||||
|
|
||||||
|
For production-grade applications, it's recommended to obtain your own API keys (to prevent rate limiting issues). You can configure these at:
|
||||||
|
|
||||||
|
- 🔷`ALCHEMY_API_KEY` variable in `packages/hardhat/.env` and `packages/nextjs/.env.local`. You can create API keys from the [Alchemy dashboard](https://dashboard.alchemy.com/).
|
||||||
|
|
||||||
|
- 📃`ETHERSCAN_API_KEY` variable in `packages/hardhat/.env` with your generated API key. You can get your key [here](https://etherscan.io/myapikey).
|
||||||
|
|
||||||
|
> 💬 Hint: It's recommended to store env's for nextjs in Vercel/system env config for live apps and use .env.local for local testing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checkpoint 7: 📜 Contract Verification
|
||||||
|
|
||||||
|
Run the `yarn verify --network your_network` command to verify your contracts on etherscan 🛰
|
||||||
|
|
||||||
|
👀 You may see an address for both YourToken and Vendor. You will want the Vendor address.
|
||||||
|
|
||||||
|
👉 Search this address on [Sepolia Etherscan](https://sepolia.etherscan.io/) (or [Optimism Sepolia Etherscan](https://sepolia-optimism.etherscan.io/) if you deployed to OP Sepolia) to get the URL you submit to 🏃♀️[SpeedrunEthereum.com](https://speedrunethereum.com).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 🏃 Head to your next challenge [here](https://speedrunethereum.com).
|
||||||
|
|
||||||
|
> 💬 Problems, questions, comments on the stack? Post them to the [🏗 scaffold-eth developers chat](https://t.me/joinchat/F7nCRK3kI93PoCOk)
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Visit our [docs](https://docs.scaffoldeth.io) to learn how to start building with Scaffold-ETH 2.
|
||||||
|
|
||||||
|
To know more about its features, check out our [website](https://scaffoldeth.io).
|
||||||
|
|
||||||
|
## Contributing to Scaffold-ETH 2
|
||||||
|
|
||||||
|
We welcome contributions to Scaffold-ETH 2!
|
||||||
|
|
||||||
|
Please see [CONTRIBUTING.MD](https://github.com/scaffold-eth/scaffold-eth-2/blob/main/CONTRIBUTING.md) for more information and guidelines for contributing to Scaffold-ETH 2.
|
||||||
60
package.json
Normal file
60
package.json
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
{
|
||||||
|
"name": "se-2",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"workspaces": {
|
||||||
|
"packages": [
|
||||||
|
"packages/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"account": "yarn hardhat:account",
|
||||||
|
"account:generate": "yarn workspace @se-2/hardhat account:generate",
|
||||||
|
"account:import": "yarn workspace @se-2/hardhat account:import",
|
||||||
|
"account:reveal-pk": "yarn workspace @se-2/hardhat account:reveal-pk",
|
||||||
|
"chain": "yarn hardhat:chain",
|
||||||
|
"compile": "yarn hardhat:compile",
|
||||||
|
"deploy": "yarn hardhat:deploy",
|
||||||
|
"fork": "yarn hardhat:fork",
|
||||||
|
"format": "yarn next:format && yarn hardhat:format",
|
||||||
|
"generate": "yarn account:generate",
|
||||||
|
"hardhat:account": "yarn workspace @se-2/hardhat account",
|
||||||
|
"hardhat:chain": "yarn workspace @se-2/hardhat chain",
|
||||||
|
"hardhat:check-types": "yarn workspace @se-2/hardhat check-types",
|
||||||
|
"hardhat:clean": "yarn workspace @se-2/hardhat clean",
|
||||||
|
"hardhat:compile": "yarn workspace @se-2/hardhat compile",
|
||||||
|
"hardhat:deploy": "yarn workspace @se-2/hardhat deploy",
|
||||||
|
"hardhat:flatten": "yarn workspace @se-2/hardhat flatten",
|
||||||
|
"hardhat:fork": "yarn workspace @se-2/hardhat fork",
|
||||||
|
"hardhat:format": "yarn workspace @se-2/hardhat format",
|
||||||
|
"hardhat:generate": "yarn workspace @se-2/hardhat generate",
|
||||||
|
"hardhat:hardhat-verify": "yarn workspace @se-2/hardhat hardhat-verify",
|
||||||
|
"hardhat:lint": "yarn workspace @se-2/hardhat lint",
|
||||||
|
"hardhat:lint-staged": "yarn workspace @se-2/hardhat lint-staged",
|
||||||
|
"hardhat:test": "yarn workspace @se-2/hardhat test",
|
||||||
|
"hardhat:verify": "yarn workspace @se-2/hardhat verify",
|
||||||
|
"postinstall": "husky install",
|
||||||
|
"ipfs": "yarn workspace @se-2/nextjs ipfs",
|
||||||
|
"lint": "yarn next:lint && yarn hardhat:lint",
|
||||||
|
"next:build": "yarn workspace @se-2/nextjs build",
|
||||||
|
"next:check-types": "yarn workspace @se-2/nextjs check-types",
|
||||||
|
"next:format": "yarn workspace @se-2/nextjs format",
|
||||||
|
"next:lint": "yarn workspace @se-2/nextjs lint",
|
||||||
|
"next:serve": "yarn workspace @se-2/nextjs serve",
|
||||||
|
"precommit": "lint-staged",
|
||||||
|
"start": "yarn workspace @se-2/nextjs dev",
|
||||||
|
"test": "yarn hardhat:test",
|
||||||
|
"vercel": "yarn workspace @se-2/nextjs vercel",
|
||||||
|
"vercel:login": "yarn workspace @se-2/nextjs vercel:login",
|
||||||
|
"vercel:yolo": "yarn workspace @se-2/nextjs vercel:yolo",
|
||||||
|
"verify": "yarn hardhat:verify"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"husky": "~9.1.6",
|
||||||
|
"lint-staged": "~13.2.2"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@3.2.3",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/hardhat/.env.example
Normal file
13
packages/hardhat/.env.example
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# Template for Hardhat environment variables.
|
||||||
|
|
||||||
|
# To use this template, copy this file, rename it .env, and fill in the values.
|
||||||
|
|
||||||
|
# If not set, we provide default values (check `hardhat.config.ts`) so developers can start prototyping out of the box,
|
||||||
|
# but we recommend getting your own API Keys for Production Apps.
|
||||||
|
|
||||||
|
# To access the values stored in this .env file you can use: process.env.VARIABLENAME
|
||||||
|
ALCHEMY_API_KEY=
|
||||||
|
ETHERSCAN_V2_API_KEY=
|
||||||
|
|
||||||
|
# Don't fill this value manually, run yarn generate to generate a new account or yarn account:import to import an existing PK.
|
||||||
|
DEPLOYER_PRIVATE_KEY_ENCRYPTED=
|
||||||
30
packages/hardhat/.gitignore
vendored
Normal file
30
packages/hardhat/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env
|
||||||
|
|
||||||
|
# coverage
|
||||||
|
coverage
|
||||||
|
coverage.json
|
||||||
|
|
||||||
|
# typechain
|
||||||
|
typechain
|
||||||
|
typechain-types
|
||||||
|
|
||||||
|
# hardhat files
|
||||||
|
cache
|
||||||
|
artifacts
|
||||||
|
|
||||||
|
# zkSync files
|
||||||
|
artifacts-zk
|
||||||
|
cache-zk
|
||||||
|
|
||||||
|
# deployments
|
||||||
|
deployments/localhost
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# other
|
||||||
|
temp
|
||||||
18
packages/hardhat/.prettierrc.json
Normal file
18
packages/hardhat/.prettierrc.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"plugins": ["prettier-plugin-solidity"],
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"printWidth": 120,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": "*.sol",
|
||||||
|
"options": {
|
||||||
|
"printWidth": 120,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"singleQuote": false,
|
||||||
|
"bracketSpacing": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
43
packages/hardhat/contracts/Vendor.sol
Normal file
43
packages/hardhat/contracts/Vendor.sol
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
pragma solidity 0.8.20; //Do not change the solidity version as it negatively impacts submission grading
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import "@openzeppelin/contracts/access/Ownable.sol";
|
||||||
|
import "./YourToken.sol";
|
||||||
|
|
||||||
|
contract Vendor is Ownable {
|
||||||
|
/////////////////
|
||||||
|
/// Errors //////
|
||||||
|
/////////////////
|
||||||
|
|
||||||
|
// Errors go here...
|
||||||
|
|
||||||
|
//////////////////////
|
||||||
|
/// State Variables //
|
||||||
|
//////////////////////
|
||||||
|
|
||||||
|
YourToken public immutable yourToken;
|
||||||
|
|
||||||
|
////////////////
|
||||||
|
/// Events /////
|
||||||
|
////////////////
|
||||||
|
|
||||||
|
// Events go here...
|
||||||
|
|
||||||
|
///////////////////
|
||||||
|
/// Constructor ///
|
||||||
|
///////////////////
|
||||||
|
|
||||||
|
constructor(address tokenAddress) Ownable(msg.sender) {
|
||||||
|
yourToken = YourToken(tokenAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
///////////////////
|
||||||
|
/// Functions /////
|
||||||
|
///////////////////
|
||||||
|
|
||||||
|
function buyTokens() external payable {}
|
||||||
|
|
||||||
|
function withdraw() public onlyOwner {}
|
||||||
|
|
||||||
|
function sellTokens(uint256 amount) public {}
|
||||||
|
}
|
||||||
10
packages/hardhat/contracts/YourToken.sol
Normal file
10
packages/hardhat/contracts/YourToken.sol
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
pragma solidity 0.8.20; //Do not change the solidity version as it negatively impacts submission grading
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
||||||
|
|
||||||
|
// learn more: https://docs.openzeppelin.com/contracts/5.x/erc20
|
||||||
|
|
||||||
|
contract YourToken is ERC20 {
|
||||||
|
constructor() ERC20("Gold", "GLD") {}
|
||||||
|
}
|
||||||
43
packages/hardhat/deploy/00_deploy_your_token.ts
Normal file
43
packages/hardhat/deploy/00_deploy_your_token.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { HardhatRuntimeEnvironment } from "hardhat/types";
|
||||||
|
import { DeployFunction } from "hardhat-deploy/types";
|
||||||
|
// import { Contract } from "ethers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deploys a contract named "YourToken" using the deployer account and
|
||||||
|
* constructor arguments set to the deployer address
|
||||||
|
*
|
||||||
|
* @param hre HardhatRuntimeEnvironment object.
|
||||||
|
*/
|
||||||
|
const deployYourToken: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
|
||||||
|
/*
|
||||||
|
On localhost, the deployer account is the one that comes with Hardhat, which is already funded.
|
||||||
|
|
||||||
|
When deploying to live networks (e.g `yarn deploy --network sepolia`), the deployer account
|
||||||
|
should have sufficient balance to pay for the gas fees for contract creation.
|
||||||
|
|
||||||
|
You can generate a random account with `yarn generate` which will fill DEPLOYER_PRIVATE_KEY
|
||||||
|
with a random private key in the .env file (then used on hardhat.config.ts)
|
||||||
|
You can run the `yarn account` command to check your balance in every network.
|
||||||
|
*/
|
||||||
|
const { deployer } = await hre.getNamedAccounts();
|
||||||
|
const { deploy } = hre.deployments;
|
||||||
|
|
||||||
|
await deploy("YourToken", {
|
||||||
|
from: deployer,
|
||||||
|
// Contract constructor arguments
|
||||||
|
args: [],
|
||||||
|
log: true,
|
||||||
|
// autoMine: can be passed to the deploy function to make the deployment process faster on local networks by
|
||||||
|
// automatically mining the contract deployment transaction. There is no effect on live networks.
|
||||||
|
autoMine: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get the deployed contract
|
||||||
|
// const yourToken = await hre.ethers.getContract<Contract>("YourToken", deployer);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default deployYourToken;
|
||||||
|
|
||||||
|
// Tags are useful if you have multiple deploy files and only want to run one of them.
|
||||||
|
// e.g. yarn deploy --tags YourToken
|
||||||
|
deployYourToken.tags = ["YourToken"];
|
||||||
75
packages/hardhat/deploy/01_deploy_vendor.ts
Normal file
75
packages/hardhat/deploy/01_deploy_vendor.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { HardhatRuntimeEnvironment } from "hardhat/types";
|
||||||
|
import { DeployFunction } from "hardhat-deploy/types";
|
||||||
|
import { Contract } from "ethers";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deploys a contract named "Vendor" using the deployer account and
|
||||||
|
* constructor arguments set to the deployer address
|
||||||
|
*
|
||||||
|
* @param hre HardhatRuntimeEnvironment object.
|
||||||
|
*/
|
||||||
|
const deployVendor: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
|
||||||
|
/*
|
||||||
|
On localhost, the deployer account is the one that comes with Hardhat, which is already funded.
|
||||||
|
|
||||||
|
When deploying to live networks (e.g `yarn deploy --network sepolia`), the deployer account
|
||||||
|
should have sufficient balance to pay for the gas fees for contract creation.
|
||||||
|
|
||||||
|
You can generate a random account with `yarn generate` which will fill DEPLOYER_PRIVATE_KEY
|
||||||
|
with a random private key in the .env file (then used on hardhat.config.ts)
|
||||||
|
You can run the `yarn account` command to check your balance in every network.
|
||||||
|
*/
|
||||||
|
const { deployer } = await hre.getNamedAccounts();
|
||||||
|
const { deploy } = hre.deployments;
|
||||||
|
const yourToken = await hre.ethers.getContract<Contract>("YourToken", deployer);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Student TODO:
|
||||||
|
* - Put the address you’re using in the frontend here (leave "" to default to the deployer)
|
||||||
|
*/
|
||||||
|
const FRONTEND_ADDRESS: string = "";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mode switch:
|
||||||
|
* - If true: deploy Vendor and seed it with the token balance
|
||||||
|
* - If false: send tokens to your frontend address (or deployer if unset)
|
||||||
|
*/
|
||||||
|
const SEND_TOKENS_TO_VENDOR = false; // Don't switch until Checkpoint 2!
|
||||||
|
|
||||||
|
const recipientAddress = FRONTEND_ADDRESS && FRONTEND_ADDRESS.trim().length > 0 ? FRONTEND_ADDRESS : deployer;
|
||||||
|
|
||||||
|
if (!SEND_TOKENS_TO_VENDOR) {
|
||||||
|
// Send the entire initial supply to the wallet you use in the UI (useful when deployer != UI wallet).
|
||||||
|
// If FRONTEND_ADDRESS is "", this defaults to the deployer (no-op transfer).
|
||||||
|
if (recipientAddress != deployer) {
|
||||||
|
await yourToken.transfer(recipientAddress, hre.ethers.parseEther("1000"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
// Deploy Vendor
|
||||||
|
const yourTokenAddress = await yourToken.getAddress();
|
||||||
|
await deploy("Vendor", {
|
||||||
|
from: deployer,
|
||||||
|
// Contract constructor arguments
|
||||||
|
args: [yourTokenAddress],
|
||||||
|
log: true,
|
||||||
|
// autoMine: can be passed to the deploy function to make the deployment process faster on local networks by
|
||||||
|
// automatically mining the contract deployment transaction. There is no effect on live networks.
|
||||||
|
autoMine: true,
|
||||||
|
});
|
||||||
|
const vendor = await hre.ethers.getContract<Contract>("Vendor", deployer);
|
||||||
|
const vendorAddress = await vendor.getAddress();
|
||||||
|
|
||||||
|
// Transfer tokens to Vendor (seed inventory)
|
||||||
|
await yourToken.transfer(vendorAddress, hre.ethers.parseEther("1000"));
|
||||||
|
|
||||||
|
// Make the UI wallet the owner (for withdraw(), etc). Defaults to deployer if unset.
|
||||||
|
await vendor.transferOwnership(recipientAddress);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default deployVendor;
|
||||||
|
|
||||||
|
// Tags are useful if you have multiple deploy files and only want to run one of them.
|
||||||
|
// e.g. yarn deploy --tags Vendor
|
||||||
|
deployVendor.tags = ["Vendor"];
|
||||||
44
packages/hardhat/eslint.config.mjs
Normal file
44
packages/hardhat/eslint.config.mjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import globals from "globals";
|
||||||
|
import tsParser from "@typescript-eslint/parser";
|
||||||
|
import prettierPlugin from "eslint-plugin-prettier";
|
||||||
|
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(["**/artifacts", "**/cache", "**/contracts", "**/node_modules/", "**/typechain-types", "**/*.json"]),
|
||||||
|
{
|
||||||
|
extends: compat.extends("plugin:@typescript-eslint/recommended", "prettier"),
|
||||||
|
|
||||||
|
plugins: {
|
||||||
|
prettier: prettierPlugin,
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
globals: {
|
||||||
|
...globals.node,
|
||||||
|
},
|
||||||
|
|
||||||
|
parser: tsParser,
|
||||||
|
},
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-unused-vars": "error",
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
|
||||||
|
"prettier/prettier": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
endOfLine: "auto",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
152
packages/hardhat/hardhat.config.ts
Normal file
152
packages/hardhat/hardhat.config.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import * as dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
import { HardhatUserConfig } from "hardhat/config";
|
||||||
|
import "@nomicfoundation/hardhat-ethers";
|
||||||
|
import "@nomicfoundation/hardhat-chai-matchers";
|
||||||
|
import "@typechain/hardhat";
|
||||||
|
import "hardhat-gas-reporter";
|
||||||
|
import "solidity-coverage";
|
||||||
|
import "@nomicfoundation/hardhat-verify";
|
||||||
|
import "hardhat-deploy";
|
||||||
|
import "hardhat-deploy-ethers";
|
||||||
|
import { task } from "hardhat/config";
|
||||||
|
import generateTsAbis from "./scripts/generateTsAbis";
|
||||||
|
|
||||||
|
// If not set, it uses ours Alchemy's default API key.
|
||||||
|
// You can get your own at https://dashboard.alchemyapi.io
|
||||||
|
const providerApiKey = process.env.ALCHEMY_API_KEY || "cR4WnXePioePZ5fFrnSiR";
|
||||||
|
// If not set, it uses the hardhat account 0 private key.
|
||||||
|
// You can generate a random account with `yarn generate` or `yarn account:import` to import your existing PK
|
||||||
|
const deployerPrivateKey =
|
||||||
|
process.env.__RUNTIME_DEPLOYER_PRIVATE_KEY ?? "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
|
||||||
|
// If not set, it uses our block explorers default API keys.
|
||||||
|
const etherscanApiKey = process.env.ETHERSCAN_V2_API_KEY || "DNXJA8RX2Q3VZ4URQIWP7Z68CJXQZSC6AW";
|
||||||
|
|
||||||
|
const config: HardhatUserConfig = {
|
||||||
|
solidity: {
|
||||||
|
compilers: [
|
||||||
|
{
|
||||||
|
version: "0.8.20",
|
||||||
|
settings: {
|
||||||
|
optimizer: {
|
||||||
|
enabled: true,
|
||||||
|
// https://docs.soliditylang.org/en/latest/using-the-compiler.html#optimizer-options
|
||||||
|
runs: 200,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
defaultNetwork: "localhost",
|
||||||
|
namedAccounts: {
|
||||||
|
deployer: {
|
||||||
|
// By default, it will take the first Hardhat account as the deployer
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
networks: {
|
||||||
|
// View the networks that are pre-configured.
|
||||||
|
// If the network you are looking for is not here you can add new network settings
|
||||||
|
hardhat: {
|
||||||
|
forking: {
|
||||||
|
url: `https://eth-mainnet.alchemyapi.io/v2/${providerApiKey}`,
|
||||||
|
enabled: process.env.MAINNET_FORKING_ENABLED === "true",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mainnet: {
|
||||||
|
url: "https://mainnet.rpc.buidlguidl.com",
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
sepolia: {
|
||||||
|
url: `https://eth-sepolia.g.alchemy.com/v2/${providerApiKey}`,
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
arbitrum: {
|
||||||
|
url: `https://arb-mainnet.g.alchemy.com/v2/${providerApiKey}`,
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
arbitrumSepolia: {
|
||||||
|
url: `https://arb-sepolia.g.alchemy.com/v2/${providerApiKey}`,
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
optimism: {
|
||||||
|
url: `https://opt-mainnet.g.alchemy.com/v2/${providerApiKey}`,
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
optimismSepolia: {
|
||||||
|
url: `https://opt-sepolia.g.alchemy.com/v2/${providerApiKey}`,
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
polygon: {
|
||||||
|
url: `https://polygon-mainnet.g.alchemy.com/v2/${providerApiKey}`,
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
polygonAmoy: {
|
||||||
|
url: `https://polygon-amoy.g.alchemy.com/v2/${providerApiKey}`,
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
polygonZkEvm: {
|
||||||
|
url: `https://polygonzkevm-mainnet.g.alchemy.com/v2/${providerApiKey}`,
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
polygonZkEvmCardona: {
|
||||||
|
url: `https://polygonzkevm-cardona.g.alchemy.com/v2/${providerApiKey}`,
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
gnosis: {
|
||||||
|
url: "https://rpc.gnosischain.com",
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
chiado: {
|
||||||
|
url: "https://rpc.chiadochain.net",
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
base: {
|
||||||
|
url: "https://mainnet.base.org",
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
baseSepolia: {
|
||||||
|
url: "https://sepolia.base.org",
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
scrollSepolia: {
|
||||||
|
url: "https://sepolia-rpc.scroll.io",
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
scroll: {
|
||||||
|
url: "https://rpc.scroll.io",
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
celo: {
|
||||||
|
url: "https://forno.celo.org",
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
celoSepolia: {
|
||||||
|
url: "https://forno.celo-sepolia.celo-testnet.org/",
|
||||||
|
accounts: [deployerPrivateKey],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Configuration for harhdat-verify plugin
|
||||||
|
etherscan: {
|
||||||
|
apiKey: etherscanApiKey,
|
||||||
|
},
|
||||||
|
// Configuration for etherscan-verify from hardhat-deploy plugin
|
||||||
|
verify: {
|
||||||
|
etherscan: {
|
||||||
|
apiKey: etherscanApiKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sourcify: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extend the deploy task
|
||||||
|
task("deploy").setAction(async (args, hre, runSuper) => {
|
||||||
|
// Run the original deploy task
|
||||||
|
await runSuper(args);
|
||||||
|
// Force run the generateTsAbis script
|
||||||
|
await generateTsAbis(hre);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default config;
|
||||||
63
packages/hardhat/package.json
Normal file
63
packages/hardhat/package.json
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "@se-2/hardhat",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"scripts": {
|
||||||
|
"account": "hardhat run scripts/listAccount.ts",
|
||||||
|
"account:generate": "hardhat run scripts/generateAccount.ts",
|
||||||
|
"account:import": "hardhat run scripts/importAccount.ts",
|
||||||
|
"account:reveal-pk": "hardhat run scripts/revealPK.ts",
|
||||||
|
"chain": "hardhat node --network hardhat --no-deploy",
|
||||||
|
"check-types": "tsc --noEmit --incremental",
|
||||||
|
"clean": "hardhat clean",
|
||||||
|
"compile": "hardhat compile",
|
||||||
|
"deploy": "ts-node scripts/runHardhatDeployWithPK.ts",
|
||||||
|
"flatten": "hardhat flatten",
|
||||||
|
"fork": "MAINNET_FORKING_ENABLED=true hardhat node --network hardhat --no-deploy",
|
||||||
|
"format": "prettier --write './**/*.(ts|sol)'",
|
||||||
|
"generate": "yarn account:generate",
|
||||||
|
"hardhat-verify": "hardhat verify",
|
||||||
|
"lint": "eslint",
|
||||||
|
"lint-staged": "eslint",
|
||||||
|
"test": "REPORT_GAS=true hardhat test --network hardhat",
|
||||||
|
"verify": "hardhat etherscan-verify"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@inquirer/password": "^4.0.2",
|
||||||
|
"@openzeppelin/contracts": "~5.0.2",
|
||||||
|
"@typechain/ethers-v6": "~0.5.1",
|
||||||
|
"dotenv": "~16.4.5",
|
||||||
|
"envfile": "~7.1.0",
|
||||||
|
"qrcode": "~1.5.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@ethersproject/abi": "~5.7.0",
|
||||||
|
"@ethersproject/providers": "~5.7.2",
|
||||||
|
"@nomicfoundation/hardhat-chai-matchers": "~2.0.7",
|
||||||
|
"@nomicfoundation/hardhat-ethers": "~3.0.8",
|
||||||
|
"@nomicfoundation/hardhat-network-helpers": "~1.0.11",
|
||||||
|
"@nomicfoundation/hardhat-verify": "~2.0.10",
|
||||||
|
"@typechain/ethers-v5": "~11.1.2",
|
||||||
|
"@typechain/hardhat": "~9.1.0",
|
||||||
|
"@types/eslint": "~9.6.1",
|
||||||
|
"@types/mocha": "~10.0.10",
|
||||||
|
"@types/prettier": "~3.0.0",
|
||||||
|
"@types/qrcode": "~1.5.5",
|
||||||
|
"@typescript-eslint/eslint-plugin": "~8.27.0",
|
||||||
|
"@typescript-eslint/parser": "~8.27.0",
|
||||||
|
"chai": "~4.5.0",
|
||||||
|
"eslint": "~9.23.0",
|
||||||
|
"eslint-config-prettier": "~10.1.1",
|
||||||
|
"eslint-plugin-prettier": "~5.2.4",
|
||||||
|
"ethers": "~6.13.2",
|
||||||
|
"hardhat": "~2.22.10",
|
||||||
|
"hardhat-deploy": "^1.0.4",
|
||||||
|
"hardhat-deploy-ethers": "~0.4.2",
|
||||||
|
"hardhat-gas-reporter": "~2.2.1",
|
||||||
|
"prettier": "^3.5.3",
|
||||||
|
"prettier-plugin-solidity": "~1.4.1",
|
||||||
|
"solidity-coverage": "~0.8.13",
|
||||||
|
"ts-node": "~10.9.1",
|
||||||
|
"typechain": "~8.3.2",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
58
packages/hardhat/scripts/generateAccount.ts
Normal file
58
packages/hardhat/scripts/generateAccount.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { ethers } from "ethers";
|
||||||
|
import { parse, stringify } from "envfile";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import password from "@inquirer/password";
|
||||||
|
|
||||||
|
const envFilePath = "./.env";
|
||||||
|
|
||||||
|
const getValidatedPassword = async () => {
|
||||||
|
while (true) {
|
||||||
|
const pass = await password({ message: "Enter a password to encrypt your private key:" });
|
||||||
|
const confirmation = await password({ message: "Confirm password:" });
|
||||||
|
|
||||||
|
if (pass === confirmation) {
|
||||||
|
return pass;
|
||||||
|
}
|
||||||
|
console.log("❌ Passwords don't match. Please try again.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setNewEnvConfig = async (existingEnvConfig = {}) => {
|
||||||
|
console.log("👛 Generating new Wallet\n");
|
||||||
|
const randomWallet = ethers.Wallet.createRandom();
|
||||||
|
|
||||||
|
const pass = await getValidatedPassword();
|
||||||
|
const encryptedJson = await randomWallet.encrypt(pass);
|
||||||
|
|
||||||
|
const newEnvConfig = {
|
||||||
|
...existingEnvConfig,
|
||||||
|
DEPLOYER_PRIVATE_KEY_ENCRYPTED: encryptedJson,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in .env
|
||||||
|
fs.writeFileSync(envFilePath, stringify(newEnvConfig));
|
||||||
|
console.log("\n📄 Encrypted Private Key saved to packages/hardhat/.env file");
|
||||||
|
console.log("🪄 Generated wallet address:", randomWallet.address, "\n");
|
||||||
|
console.log("⚠️ Make sure to remember your password! You'll need it to decrypt the private key.");
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!fs.existsSync(envFilePath)) {
|
||||||
|
// No .env file yet.
|
||||||
|
await setNewEnvConfig();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEnvConfig = parse(fs.readFileSync(envFilePath).toString());
|
||||||
|
if (existingEnvConfig.DEPLOYER_PRIVATE_KEY_ENCRYPTED) {
|
||||||
|
console.log("⚠️ You already have a deployer account. Check the packages/hardhat/.env file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setNewEnvConfig(existingEnvConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
136
packages/hardhat/scripts/generateTsAbis.ts
Normal file
136
packages/hardhat/scripts/generateTsAbis.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
/**
|
||||||
|
* DON'T MODIFY OR DELETE THIS SCRIPT (unless you know what you're doing)
|
||||||
|
*
|
||||||
|
* This script generates the file containing the contracts Abi definitions.
|
||||||
|
* These definitions are used to derive the types needed in the custom scaffold-eth hooks, for example.
|
||||||
|
* This script should run as the last deploy script.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from "fs";
|
||||||
|
import prettier from "prettier";
|
||||||
|
import { DeployFunction } from "hardhat-deploy/types";
|
||||||
|
|
||||||
|
const generatedContractComment = `
|
||||||
|
/**
|
||||||
|
* This file is autogenerated by Scaffold-ETH.
|
||||||
|
* You should not edit it manually or your changes might be overwritten.
|
||||||
|
*/
|
||||||
|
`;
|
||||||
|
|
||||||
|
const DEPLOYMENTS_DIR = "./deployments";
|
||||||
|
const ARTIFACTS_DIR = "./artifacts";
|
||||||
|
|
||||||
|
function getDirectories(path: string) {
|
||||||
|
return fs
|
||||||
|
.readdirSync(path, { withFileTypes: true })
|
||||||
|
.filter(dirent => dirent.isDirectory())
|
||||||
|
.map(dirent => dirent.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContractNames(path: string) {
|
||||||
|
return fs
|
||||||
|
.readdirSync(path, { withFileTypes: true })
|
||||||
|
.filter(dirent => dirent.isFile() && dirent.name.endsWith(".json"))
|
||||||
|
.map(dirent => dirent.name.split(".")[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActualSourcesForContract(sources: Record<string, any>, contractName: string) {
|
||||||
|
for (const sourcePath of Object.keys(sources)) {
|
||||||
|
const sourceName = sourcePath.split("/").pop()?.split(".sol")[0];
|
||||||
|
if (sourceName === contractName) {
|
||||||
|
const contractContent = sources[sourcePath].content as string;
|
||||||
|
const regex = /contract\s+(\w+)\s+is\s+([^{}]+)\{/;
|
||||||
|
const match = contractContent.match(regex);
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
const inheritancePart = match[2];
|
||||||
|
// Split the inherited contracts by commas to get the list of inherited contracts
|
||||||
|
const inheritedContracts = inheritancePart.split(",").map(contract => `${contract.trim()}.sol`);
|
||||||
|
|
||||||
|
return inheritedContracts;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInheritedFunctions(sources: Record<string, any>, contractName: string) {
|
||||||
|
const actualSources = getActualSourcesForContract(sources, contractName);
|
||||||
|
const inheritedFunctions = {} as Record<string, any>;
|
||||||
|
|
||||||
|
for (const sourceContractName of actualSources) {
|
||||||
|
const sourcePath = Object.keys(sources).find(key => key.includes(`/${sourceContractName}`));
|
||||||
|
if (sourcePath) {
|
||||||
|
const sourceName = sourcePath?.split("/").pop()?.split(".sol")[0];
|
||||||
|
const { abi } = JSON.parse(fs.readFileSync(`${ARTIFACTS_DIR}/${sourcePath}/${sourceName}.json`).toString());
|
||||||
|
for (const functionAbi of abi) {
|
||||||
|
if (functionAbi.type === "function") {
|
||||||
|
inheritedFunctions[functionAbi.name] = sourcePath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return inheritedFunctions;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContractDataFromDeployments() {
|
||||||
|
if (!fs.existsSync(DEPLOYMENTS_DIR)) {
|
||||||
|
throw Error("At least one other deployment script should exist to generate an actual contract.");
|
||||||
|
}
|
||||||
|
const output = {} as Record<string, any>;
|
||||||
|
const chainDirectories = getDirectories(DEPLOYMENTS_DIR);
|
||||||
|
for (const chainName of chainDirectories) {
|
||||||
|
let chainId;
|
||||||
|
try {
|
||||||
|
chainId = fs.readFileSync(`${DEPLOYMENTS_DIR}/${chainName}/.chainId`).toString();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`No chainId file found for ${chainName}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contracts = {} as Record<string, any>;
|
||||||
|
for (const contractName of getContractNames(`${DEPLOYMENTS_DIR}/${chainName}`)) {
|
||||||
|
const { abi, address, metadata, receipt } = JSON.parse(
|
||||||
|
fs.readFileSync(`${DEPLOYMENTS_DIR}/${chainName}/${contractName}.json`).toString(),
|
||||||
|
);
|
||||||
|
const inheritedFunctions = metadata ? getInheritedFunctions(JSON.parse(metadata).sources, contractName) : {};
|
||||||
|
contracts[contractName] = { address, abi, inheritedFunctions, deployedOnBlock: receipt?.blockNumber };
|
||||||
|
}
|
||||||
|
output[chainId] = contracts;
|
||||||
|
}
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates the TypeScript contract definition file based on the json output of the contract deployment scripts
|
||||||
|
* This script should be run last.
|
||||||
|
*/
|
||||||
|
const generateTsAbis: DeployFunction = async function () {
|
||||||
|
const TARGET_DIR = "../nextjs/contracts/";
|
||||||
|
const allContractsData = getContractDataFromDeployments();
|
||||||
|
|
||||||
|
const fileContent = Object.entries(allContractsData).reduce((content, [chainId, chainConfig]) => {
|
||||||
|
return `${content}${parseInt(chainId).toFixed(0)}:${JSON.stringify(chainConfig, null, 2)},`;
|
||||||
|
}, "");
|
||||||
|
|
||||||
|
if (!fs.existsSync(TARGET_DIR)) {
|
||||||
|
fs.mkdirSync(TARGET_DIR);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(
|
||||||
|
`${TARGET_DIR}deployedContracts.ts`,
|
||||||
|
await prettier.format(
|
||||||
|
`${generatedContractComment} import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract"; \n\n
|
||||||
|
const deployedContracts = {${fileContent}} as const; \n\n export default deployedContracts satisfies GenericContractsDeclaration`,
|
||||||
|
{
|
||||||
|
parser: "typescript",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`📝 Updated TypeScript contract definition file on ${TARGET_DIR}deployedContracts.ts`);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default generateTsAbis;
|
||||||
72
packages/hardhat/scripts/importAccount.ts
Normal file
72
packages/hardhat/scripts/importAccount.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { ethers } from "ethers";
|
||||||
|
import { parse, stringify } from "envfile";
|
||||||
|
import * as fs from "fs";
|
||||||
|
import password from "@inquirer/password";
|
||||||
|
|
||||||
|
const envFilePath = "./.env";
|
||||||
|
|
||||||
|
const getValidatedPassword = async () => {
|
||||||
|
while (true) {
|
||||||
|
const pass = await password({ message: "Enter a password to encrypt your private key:" });
|
||||||
|
const confirmation = await password({ message: "Confirm password:" });
|
||||||
|
|
||||||
|
if (pass === confirmation) {
|
||||||
|
return pass;
|
||||||
|
}
|
||||||
|
console.log("❌ Passwords don't match. Please try again.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWalletFromPrivateKey = async () => {
|
||||||
|
while (true) {
|
||||||
|
const privateKey = await password({ message: "Paste your private key:" });
|
||||||
|
try {
|
||||||
|
const wallet = new ethers.Wallet(privateKey);
|
||||||
|
return wallet;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
} catch (e) {
|
||||||
|
console.log("❌ Invalid private key format. Please try again.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setNewEnvConfig = async (existingEnvConfig = {}) => {
|
||||||
|
console.log("👛 Importing Wallet\n");
|
||||||
|
|
||||||
|
const wallet = await getWalletFromPrivateKey();
|
||||||
|
|
||||||
|
const pass = await getValidatedPassword();
|
||||||
|
const encryptedJson = await wallet.encrypt(pass);
|
||||||
|
|
||||||
|
const newEnvConfig = {
|
||||||
|
...existingEnvConfig,
|
||||||
|
DEPLOYER_PRIVATE_KEY_ENCRYPTED: encryptedJson,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store in .env
|
||||||
|
fs.writeFileSync(envFilePath, stringify(newEnvConfig));
|
||||||
|
console.log("\n📄 Encrypted Private Key saved to packages/hardhat/.env file");
|
||||||
|
console.log("🪄 Imported wallet address:", wallet.address, "\n");
|
||||||
|
console.log("⚠️ Make sure to remember your password! You'll need it to decrypt the private key.");
|
||||||
|
};
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
if (!fs.existsSync(envFilePath)) {
|
||||||
|
// No .env file yet.
|
||||||
|
await setNewEnvConfig();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEnvConfig = parse(fs.readFileSync(envFilePath).toString());
|
||||||
|
if (existingEnvConfig.DEPLOYER_PRIVATE_KEY_ENCRYPTED) {
|
||||||
|
console.log("⚠️ You already have a deployer account. Check the packages/hardhat/.env file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setNewEnvConfig(existingEnvConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
52
packages/hardhat/scripts/listAccount.ts
Normal file
52
packages/hardhat/scripts/listAccount.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import * as dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
import { ethers, Wallet } from "ethers";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
import { config } from "hardhat";
|
||||||
|
import password from "@inquirer/password";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
|
||||||
|
|
||||||
|
if (!encryptedKey) {
|
||||||
|
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pass = await password({ message: "Enter your password to decrypt the private key:" });
|
||||||
|
let wallet: Wallet;
|
||||||
|
try {
|
||||||
|
wallet = (await Wallet.fromEncryptedJson(encryptedKey, pass)) as Wallet;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
} catch (e) {
|
||||||
|
console.log("❌ Failed to decrypt private key. Wrong password?");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = wallet.address;
|
||||||
|
console.log(await QRCode.toString(address, { type: "terminal", small: true }));
|
||||||
|
console.log("Public address:", address, "\n");
|
||||||
|
|
||||||
|
// Balance on each network
|
||||||
|
const availableNetworks = config.networks;
|
||||||
|
for (const networkName in availableNetworks) {
|
||||||
|
try {
|
||||||
|
const network = availableNetworks[networkName];
|
||||||
|
if (!("url" in network)) continue;
|
||||||
|
const provider = new ethers.JsonRpcProvider(network.url);
|
||||||
|
await provider._detectNetwork();
|
||||||
|
const balance = await provider.getBalance(address);
|
||||||
|
console.log("--", networkName, "-- 📡");
|
||||||
|
console.log(" balance:", +ethers.formatEther(balance));
|
||||||
|
console.log(" nonce:", +(await provider.getTransactionCount(address)));
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
} catch (e) {
|
||||||
|
console.log("Can't connect to network", networkName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
31
packages/hardhat/scripts/revealPK.ts
Normal file
31
packages/hardhat/scripts/revealPK.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
import { Wallet } from "ethers";
|
||||||
|
import password from "@inquirer/password";
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
|
||||||
|
|
||||||
|
if (!encryptedKey) {
|
||||||
|
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("👀 This will reveal your private key on the console.\n");
|
||||||
|
|
||||||
|
const pass = await password({ message: "Enter your password to decrypt the private key:" });
|
||||||
|
let wallet: Wallet;
|
||||||
|
try {
|
||||||
|
wallet = (await Wallet.fromEncryptedJson(encryptedKey, pass)) as Wallet;
|
||||||
|
} catch {
|
||||||
|
console.log("❌ Failed to decrypt private key. Wrong password?");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n🔑 Private key:", wallet.privateKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
58
packages/hardhat/scripts/runHardhatDeployWithPK.ts
Normal file
58
packages/hardhat/scripts/runHardhatDeployWithPK.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import * as dotenv from "dotenv";
|
||||||
|
dotenv.config();
|
||||||
|
import { Wallet } from "ethers";
|
||||||
|
import password from "@inquirer/password";
|
||||||
|
import { spawn } from "child_process";
|
||||||
|
import { config } from "hardhat";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unencrypts the private key and runs the hardhat deploy command
|
||||||
|
*/
|
||||||
|
async function main() {
|
||||||
|
const networkIndex = process.argv.indexOf("--network");
|
||||||
|
const networkName = networkIndex !== -1 ? process.argv[networkIndex + 1] : config.defaultNetwork;
|
||||||
|
|
||||||
|
if (networkName === "localhost" || networkName === "hardhat") {
|
||||||
|
// Deploy command on the localhost network
|
||||||
|
const hardhat = spawn("hardhat", ["deploy", ...process.argv.slice(2)], {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: process.env,
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
|
||||||
|
hardhat.on("exit", code => {
|
||||||
|
process.exit(code || 0);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
|
||||||
|
|
||||||
|
if (!encryptedKey) {
|
||||||
|
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pass = await password({ message: "Enter password to decrypt private key:" });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const wallet = await Wallet.fromEncryptedJson(encryptedKey, pass);
|
||||||
|
process.env.__RUNTIME_DEPLOYER_PRIVATE_KEY = wallet.privateKey;
|
||||||
|
|
||||||
|
const hardhat = spawn("hardhat", ["deploy", ...process.argv.slice(2)], {
|
||||||
|
stdio: "inherit",
|
||||||
|
env: process.env,
|
||||||
|
shell: process.platform === "win32",
|
||||||
|
});
|
||||||
|
|
||||||
|
hardhat.on("exit", code => {
|
||||||
|
process.exit(code || 0);
|
||||||
|
});
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to decrypt private key. Wrong password?");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
2
packages/hardhat/test/.gitkeep
Normal file
2
packages/hardhat/test/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Write tests for your smart contract in this directory
|
||||||
|
# Example: YourContract.ts
|
||||||
241
packages/hardhat/test/Vendor.ts
Normal file
241
packages/hardhat/test/Vendor.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
//
|
||||||
|
// this script executes when you run 'yarn harhat:test'
|
||||||
|
//
|
||||||
|
|
||||||
|
import hre from "hardhat";
|
||||||
|
import { expect } from "chai";
|
||||||
|
import { impersonateAccount, stopImpersonatingAccount } from "@nomicfoundation/hardhat-network-helpers";
|
||||||
|
import { Vendor, YourToken } from "../typechain-types";
|
||||||
|
|
||||||
|
const { ethers } = hre;
|
||||||
|
|
||||||
|
describe("🚩 Challenge: 🏵 Token Vendor 🤖", function () {
|
||||||
|
// NOTE: The README expects tests grouped by checkpoint so you can run:
|
||||||
|
// yarn test --grep "Checkpoint1"
|
||||||
|
// yarn test --grep "Checkpoint2"
|
||||||
|
// yarn test --grep "Checkpoint3"
|
||||||
|
// yarn test --grep "Checkpoint4"
|
||||||
|
|
||||||
|
const contractAddress = process.env.CONTRACT_ADDRESS;
|
||||||
|
|
||||||
|
const getYourTokenArtifact = () => {
|
||||||
|
if (contractAddress) return "contracts/YourTokenAutograder.sol:YourToken";
|
||||||
|
return "contracts/YourToken.sol:YourToken";
|
||||||
|
};
|
||||||
|
|
||||||
|
const getVendorArtifact = () => {
|
||||||
|
if (process.env.CONTRACT_ADDRESS) return `contracts/download-${process.env.CONTRACT_ADDRESS}.sol:Vendor`;
|
||||||
|
return "contracts/Vendor.sol:Vendor";
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOKENS_PER_ETH = 100n;
|
||||||
|
const INITIAL_SUPPLY = ethers.parseEther("1000");
|
||||||
|
|
||||||
|
async function deployYourTokenFixture() {
|
||||||
|
const [deployer, user] = await ethers.getSigners();
|
||||||
|
const YourTokenFactory = await ethers.getContractFactory(getYourTokenArtifact());
|
||||||
|
const yourToken = (await YourTokenFactory.deploy()) as YourToken;
|
||||||
|
await yourToken.waitForDeployment();
|
||||||
|
return { deployer, user, yourToken, yourTokenAddress: await yourToken.getAddress() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deployVendorFixture(yourTokenAddress: string) {
|
||||||
|
const VendorFactory = await ethers.getContractFactory(getVendorArtifact());
|
||||||
|
const vendor = (await VendorFactory.deploy(yourTokenAddress)) as Vendor;
|
||||||
|
await vendor.waitForDeployment();
|
||||||
|
return { vendor, vendorAddress: await vendor.getAddress() };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Checkpoint1: 🏵 Your Token (ERC20 mint + transfer)", function () {
|
||||||
|
it("Checkpoint1: mints exactly 1000 tokens (18 decimals) to the deployer", async function () {
|
||||||
|
const { deployer, yourToken } = await deployYourTokenFixture();
|
||||||
|
|
||||||
|
const totalSupply = await yourToken.totalSupply();
|
||||||
|
expect(totalSupply).to.equal(INITIAL_SUPPLY);
|
||||||
|
|
||||||
|
const deployerBalance = await yourToken.balanceOf(deployer.address);
|
||||||
|
expect(deployerBalance).to.equal(INITIAL_SUPPLY);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Checkpoint1: can transfer tokens and balanceOf updates correctly", async function () {
|
||||||
|
const { deployer, user, yourToken } = await deployYourTokenFixture();
|
||||||
|
|
||||||
|
const amount = ethers.parseEther("10");
|
||||||
|
await expect(yourToken.transfer(user.address, amount)).to.not.be.reverted;
|
||||||
|
|
||||||
|
expect(await yourToken.balanceOf(user.address)).to.equal(amount);
|
||||||
|
expect(await yourToken.balanceOf(deployer.address)).to.equal(INITIAL_SUPPLY - amount);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Checkpoint2: ⚖️ Vendor buyTokens()", function () {
|
||||||
|
it("Checkpoint2: tokensPerEth constant is 100", async function () {
|
||||||
|
const { yourTokenAddress } = await deployYourTokenFixture();
|
||||||
|
const { vendor } = await deployVendorFixture(yourTokenAddress);
|
||||||
|
|
||||||
|
expect(await vendor.tokensPerEth()).to.equal(TOKENS_PER_ETH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Checkpoint2: buyTokens reverts on 0 ETH with InvalidEthAmount", async function () {
|
||||||
|
const { yourTokenAddress } = await deployYourTokenFixture();
|
||||||
|
const { vendor } = await deployVendorFixture(yourTokenAddress);
|
||||||
|
|
||||||
|
await expect(vendor.buyTokens({ value: 0 })).to.be.revertedWithCustomError(vendor, "InvalidEthAmount");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Checkpoint2: can buy 10 tokens for 0.1 ETH (and emits BuyTokens)", async function () {
|
||||||
|
const { user, yourToken, yourTokenAddress } = await deployYourTokenFixture();
|
||||||
|
const { vendor, vendorAddress } = await deployVendorFixture(yourTokenAddress);
|
||||||
|
|
||||||
|
// Seed the Vendor with the full supply so it can sell.
|
||||||
|
await yourToken.transfer(vendorAddress, INITIAL_SUPPLY);
|
||||||
|
|
||||||
|
const ethToSpend = ethers.parseEther("0.1");
|
||||||
|
const expectedTokens = ethToSpend * TOKENS_PER_ETH; // 0.1 ETH * 100 = 10 tokens (18 decimals)
|
||||||
|
|
||||||
|
const startingBalance = await yourToken.balanceOf(user.address);
|
||||||
|
const tx = vendor.connect(user).buyTokens({ value: ethToSpend });
|
||||||
|
|
||||||
|
await expect(tx).to.emit(vendor, "BuyTokens").withArgs(user.address, ethToSpend, expectedTokens);
|
||||||
|
|
||||||
|
expect(await yourToken.balanceOf(user.address)).to.equal(startingBalance + expectedTokens);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Checkpoint2: reverts if Vendor does not have enough tokens (InsufficientVendorTokenBalance)", async function () {
|
||||||
|
const { yourTokenAddress } = await deployYourTokenFixture();
|
||||||
|
const { vendor } = await deployVendorFixture(yourTokenAddress);
|
||||||
|
|
||||||
|
const ethToSpend = ethers.parseEther("1");
|
||||||
|
const requiredTokens = ethToSpend * TOKENS_PER_ETH;
|
||||||
|
await expect(vendor.buyTokens({ value: ethToSpend }))
|
||||||
|
.to.be.revertedWithCustomError(vendor, "InsufficientVendorTokenBalance")
|
||||||
|
.withArgs(0, requiredTokens);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Checkpoint3: 👑 Ownable + withdraw()", function () {
|
||||||
|
it("Checkpoint3: deployer is the owner", async function () {
|
||||||
|
const { deployer, yourTokenAddress } = await deployYourTokenFixture();
|
||||||
|
const { vendor } = await deployVendorFixture(yourTokenAddress);
|
||||||
|
|
||||||
|
expect(await vendor.owner()).to.equal(deployer.address);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Checkpoint3: only owner can withdraw (non-owner is rejected)", async function () {
|
||||||
|
const { user, yourTokenAddress } = await deployYourTokenFixture();
|
||||||
|
const { vendor } = await deployVendorFixture(yourTokenAddress);
|
||||||
|
|
||||||
|
await expect(vendor.connect(user).withdraw()).to.be.reverted;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Checkpoint3: withdraw sends all ETH in Vendor to the owner", async function () {
|
||||||
|
const { deployer, user, yourToken, yourTokenAddress } = await deployYourTokenFixture();
|
||||||
|
const { vendor, vendorAddress } = await deployVendorFixture(yourTokenAddress);
|
||||||
|
|
||||||
|
// Seed Vendor with tokens and buy tokens to fund Vendor with ETH.
|
||||||
|
await yourToken.transfer(vendorAddress, INITIAL_SUPPLY);
|
||||||
|
await vendor.connect(user).buyTokens({ value: ethers.parseEther("0.1") });
|
||||||
|
|
||||||
|
const vendorEthBefore = await ethers.provider.getBalance(vendorAddress);
|
||||||
|
expect(vendorEthBefore).to.be.gt(0);
|
||||||
|
|
||||||
|
const ownerEthBefore = await ethers.provider.getBalance(deployer.address);
|
||||||
|
const withdrawTx = await vendor.withdraw();
|
||||||
|
const receipt = await withdrawTx.wait();
|
||||||
|
expect(receipt?.status).to.equal(1);
|
||||||
|
|
||||||
|
const tx = await ethers.provider.getTransaction(withdrawTx.hash);
|
||||||
|
if (!tx || !receipt) throw new Error("Cannot resolve withdraw tx/receipt");
|
||||||
|
const gasPrice = (receipt as any).effectiveGasPrice ?? tx.gasPrice ?? 0n;
|
||||||
|
const gasCost = (receipt.gasUsed ?? 0n) * gasPrice;
|
||||||
|
|
||||||
|
const ownerEthAfter = await ethers.provider.getBalance(deployer.address);
|
||||||
|
const vendorEthAfter = await ethers.provider.getBalance(vendorAddress);
|
||||||
|
|
||||||
|
expect(vendorEthAfter).to.equal(0);
|
||||||
|
expect(ownerEthAfter).to.equal(ownerEthBefore + vendorEthBefore - gasCost);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Checkpoint3: withdraw reverts with EthTransferFailed if the owner can't receive ETH", async function () {
|
||||||
|
const { deployer, user, yourToken, yourTokenAddress } = await deployYourTokenFixture();
|
||||||
|
const { vendor, vendorAddress } = await deployVendorFixture(yourTokenAddress);
|
||||||
|
|
||||||
|
// Seed Vendor with tokens and fund Vendor with ETH.
|
||||||
|
await yourToken.transfer(vendorAddress, INITIAL_SUPPLY);
|
||||||
|
await vendor.connect(user).buyTokens({ value: ethers.parseEther("0.1") });
|
||||||
|
|
||||||
|
// Make the Vendor the owner of itself. Since Vendor has no receive()/payable fallback,
|
||||||
|
// sending ETH to it will revert by default, which should trigger EthTransferFailed.
|
||||||
|
await vendor.connect(deployer).transferOwnership(vendorAddress);
|
||||||
|
|
||||||
|
const vendorEthBefore = await ethers.provider.getBalance(vendorAddress);
|
||||||
|
|
||||||
|
await impersonateAccount(vendorAddress);
|
||||||
|
try {
|
||||||
|
const vendorAsOwner = await ethers.getSigner(vendorAddress);
|
||||||
|
// Use a simulation call (no gas is paid from vendorAddress), otherwise the tx gas would
|
||||||
|
// be deducted from vendorAddress and `address(this).balance` would be smaller than vendorEthBefore.
|
||||||
|
await expect(vendor.connect(vendorAsOwner).withdraw.staticCall())
|
||||||
|
.to.be.revertedWithCustomError(vendor, "EthTransferFailed")
|
||||||
|
.withArgs(vendorAddress, vendorEthBefore);
|
||||||
|
} finally {
|
||||||
|
await stopImpersonatingAccount(vendorAddress);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Checkpoint4: 🤔 Vendor buyback (sellTokens + approve)", function () {
|
||||||
|
it("Checkpoint4: sellTokens rejects amount == 0 (InvalidTokenAmount)", async function () {
|
||||||
|
const { user, yourTokenAddress } = await deployYourTokenFixture();
|
||||||
|
const { vendor } = await deployVendorFixture(yourTokenAddress);
|
||||||
|
|
||||||
|
await expect(vendor.connect(user).sellTokens(0)).to.be.revertedWithCustomError(vendor, "InvalidTokenAmount");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Checkpoint4: sellTokens reverts if Vendor lacks ETH liquidity (InsufficientVendorEthBalance)", async function () {
|
||||||
|
const { user, yourToken, yourTokenAddress } = await deployYourTokenFixture();
|
||||||
|
const { vendor, vendorAddress } = await deployVendorFixture(yourTokenAddress);
|
||||||
|
|
||||||
|
// Give the user tokens directly so they can attempt to sell back.
|
||||||
|
await yourToken.transfer(user.address, ethers.parseEther("10"));
|
||||||
|
await yourToken.connect(user).approve(vendorAddress, ethers.parseEther("10"));
|
||||||
|
|
||||||
|
const amountToSell = ethers.parseEther("10"); // 10 tokens -> expects 0.1 ETH back
|
||||||
|
const expectedEth = amountToSell / TOKENS_PER_ETH;
|
||||||
|
|
||||||
|
await expect(vendor.connect(user).sellTokens(amountToSell))
|
||||||
|
.to.be.revertedWithCustomError(vendor, "InsufficientVendorEthBalance")
|
||||||
|
.withArgs(0, expectedEth);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Checkpoint4: approve + sellTokens returns correct ETH (and emits SellTokens)", async function () {
|
||||||
|
const { user, yourToken, yourTokenAddress } = await deployYourTokenFixture();
|
||||||
|
const { vendor, vendorAddress } = await deployVendorFixture(yourTokenAddress);
|
||||||
|
|
||||||
|
// Seed vendor with tokens and fund vendor with ETH by buying first.
|
||||||
|
await yourToken.transfer(vendorAddress, INITIAL_SUPPLY);
|
||||||
|
await vendor.connect(user).buyTokens({ value: ethers.parseEther("0.1") }); // user receives 10 tokens
|
||||||
|
|
||||||
|
const amountToSell = ethers.parseEther("10");
|
||||||
|
const expectedEth = amountToSell / TOKENS_PER_ETH; // 0.1 ETH
|
||||||
|
|
||||||
|
await yourToken.connect(user).approve(vendorAddress, amountToSell);
|
||||||
|
|
||||||
|
const userEthBefore = await ethers.provider.getBalance(user.address);
|
||||||
|
const tx = await vendor.connect(user).sellTokens(amountToSell);
|
||||||
|
|
||||||
|
await expect(tx).to.emit(vendor, "SellTokens").withArgs(user.address, amountToSell, expectedEth);
|
||||||
|
|
||||||
|
const receipt = await tx.wait();
|
||||||
|
expect(receipt?.status).to.equal(1);
|
||||||
|
|
||||||
|
const userEthAfter = await ethers.provider.getBalance(user.address);
|
||||||
|
// ethers v6 receipts expose `gasPrice` (effective gas price). Some toolchains expose `effectiveGasPrice`.
|
||||||
|
const effectiveGasPrice = ((receipt as any)?.gasPrice ?? (receipt as any)?.effectiveGasPrice ?? 0n) as bigint;
|
||||||
|
const gasCost = (receipt?.gasUsed ?? 0n) * effectiveGasPrice;
|
||||||
|
|
||||||
|
// ETH change = +expectedEth - gas
|
||||||
|
expect(userEthAfter).to.equal(userEthBefore + expectedEth - gasCost);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
11
packages/hardhat/tsconfig.json
Normal file
11
packages/hardhat/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/nextjs/.env.example
Normal file
14
packages/nextjs/.env.example
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Template for NextJS environment variables.
|
||||||
|
|
||||||
|
# For local development, copy this file, rename it to .env.local, and fill in the values.
|
||||||
|
# When deploying live, you'll need to store the vars in Vercel/System config.
|
||||||
|
|
||||||
|
# If not set, we provide default values (check `scaffold.config.ts`) so developers can start prototyping out of the box,
|
||||||
|
# but we recommend getting your own API Keys for Production Apps.
|
||||||
|
|
||||||
|
# To access the values stored in this env file you can use: process.env.VARIABLENAME
|
||||||
|
# You'll need to prefix the variables names with NEXT_PUBLIC_ if you want to access them on the client side.
|
||||||
|
# More info: https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables
|
||||||
|
NEXT_PUBLIC_ALCHEMY_API_KEY=
|
||||||
|
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=
|
||||||
|
|
||||||
38
packages/nextjs/.gitignore
vendored
Normal file
38
packages/nextjs/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
/coverage
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# local env files
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
ipfs-upload.config.json
|
||||||
9
packages/nextjs/.prettierrc.js
Normal file
9
packages/nextjs/.prettierrc.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
module.exports = {
|
||||||
|
arrowParens: "avoid",
|
||||||
|
printWidth: 120,
|
||||||
|
tabWidth: 2,
|
||||||
|
trailingComma: "all",
|
||||||
|
importOrder: ["^react$", "^next/(.*)$", "<THIRD_PARTY_MODULES>", "^@heroicons/(.*)$", "^~~/(.*)$"],
|
||||||
|
importOrderSortSpecifiers: true,
|
||||||
|
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
type AddressCodeTabProps = {
|
||||||
|
bytecode: string;
|
||||||
|
assembly: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddressCodeTab = ({ bytecode, assembly }: AddressCodeTabProps) => {
|
||||||
|
const formattedAssembly = Array.from(assembly.matchAll(/\w+( 0x[a-fA-F0-9]+)?/g))
|
||||||
|
.map(it => it[0])
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
Bytecode
|
||||||
|
<div className="mockup-code -indent-5 overflow-y-auto max-h-[500px]">
|
||||||
|
<pre className="px-5">
|
||||||
|
<code className="whitespace-pre-wrap overflow-auto break-words">{bytecode}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
Opcodes
|
||||||
|
<div className="mockup-code -indent-5 overflow-y-auto max-h-[500px]">
|
||||||
|
<pre className="px-5">
|
||||||
|
<code>{formattedAssembly}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { BackButton } from "./BackButton";
|
||||||
|
import { ContractTabs } from "./ContractTabs";
|
||||||
|
import { Address, Balance } from "@scaffold-ui/components";
|
||||||
|
import { Address as AddressType } from "viem";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { useTargetNetwork } from "~~/hooks/scaffold-eth";
|
||||||
|
|
||||||
|
export const AddressComponent = ({
|
||||||
|
address,
|
||||||
|
contractData,
|
||||||
|
}: {
|
||||||
|
address: AddressType;
|
||||||
|
contractData: { bytecode: string; assembly: string } | null;
|
||||||
|
}) => {
|
||||||
|
const { targetNetwork } = useTargetNetwork();
|
||||||
|
return (
|
||||||
|
<div className="m-10 mb-20">
|
||||||
|
<div className="flex justify-start mb-5">
|
||||||
|
<BackButton />
|
||||||
|
</div>
|
||||||
|
<div className="col-span-5 grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-10">
|
||||||
|
<div className="col-span-1 flex flex-col">
|
||||||
|
<div className="bg-base-100 border-base-300 border shadow-md shadow-secondary rounded-3xl px-6 lg:px-8 mb-6 space-y-1 py-4 overflow-x-auto">
|
||||||
|
<div className="flex">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<Address
|
||||||
|
address={address}
|
||||||
|
format="long"
|
||||||
|
onlyEnsOrAddress
|
||||||
|
blockExplorerAddressLink={
|
||||||
|
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${address}` : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1 items-center">
|
||||||
|
<span className="font-bold text-sm">Balance:</span>
|
||||||
|
<Balance address={address} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ContractTabs address={address} contractData={contractData} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Address } from "viem";
|
||||||
|
import { useContractLogs } from "~~/hooks/scaffold-eth";
|
||||||
|
import { replacer } from "~~/utils/scaffold-eth/common";
|
||||||
|
|
||||||
|
export const AddressLogsTab = ({ address }: { address: Address }) => {
|
||||||
|
const contractLogs = useContractLogs(address);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
<div className="mockup-code overflow-auto max-h-[500px]">
|
||||||
|
<pre className="px-5 whitespace-pre-wrap break-words">
|
||||||
|
{contractLogs.map((log, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<strong>Log:</strong> {JSON.stringify(log, replacer, 2)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Address, createPublicClient, http, toHex } from "viem";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
|
||||||
|
const publicClient = createPublicClient({
|
||||||
|
chain: hardhat,
|
||||||
|
transport: http(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AddressStorageTab = ({ address }: { address: Address }) => {
|
||||||
|
const [storage, setStorage] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchStorage = async () => {
|
||||||
|
try {
|
||||||
|
const storageData = [];
|
||||||
|
let idx = 0;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const storageAtPosition = await publicClient.getStorageAt({
|
||||||
|
address: address,
|
||||||
|
slot: toHex(idx),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (storageAtPosition === "0x" + "0".repeat(64)) break;
|
||||||
|
|
||||||
|
if (storageAtPosition) {
|
||||||
|
storageData.push(storageAtPosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
idx++;
|
||||||
|
}
|
||||||
|
setStorage(storageData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch storage:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchStorage();
|
||||||
|
}, [address]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
{storage.length > 0 ? (
|
||||||
|
<div className="mockup-code overflow-auto max-h-[500px]">
|
||||||
|
<pre className="px-5 whitespace-pre-wrap break-words">
|
||||||
|
{storage.map((data, i) => (
|
||||||
|
<div key={i}>
|
||||||
|
<strong>Storage Slot {i}:</strong> {data}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-lg">This contract does not have any variables.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
12
packages/nextjs/app/blockexplorer/_components/BackButton.tsx
Normal file
12
packages/nextjs/app/blockexplorer/_components/BackButton.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
export const BackButton = () => {
|
||||||
|
const router = useRouter();
|
||||||
|
return (
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={() => router.back()}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
102
packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx
Normal file
102
packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { AddressCodeTab } from "./AddressCodeTab";
|
||||||
|
import { AddressLogsTab } from "./AddressLogsTab";
|
||||||
|
import { AddressStorageTab } from "./AddressStorageTab";
|
||||||
|
import { PaginationButton } from "./PaginationButton";
|
||||||
|
import { TransactionsTable } from "./TransactionsTable";
|
||||||
|
import { Address, createPublicClient, http } from "viem";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { useFetchBlocks } from "~~/hooks/scaffold-eth";
|
||||||
|
|
||||||
|
type AddressCodeTabProps = {
|
||||||
|
bytecode: string;
|
||||||
|
assembly: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
address: Address;
|
||||||
|
contractData: AddressCodeTabProps | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const publicClient = createPublicClient({
|
||||||
|
chain: hardhat,
|
||||||
|
transport: http(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ContractTabs = ({ address, contractData }: PageProps) => {
|
||||||
|
const { blocks, transactionReceipts, currentPage, totalBlocks, setCurrentPage } = useFetchBlocks();
|
||||||
|
const [activeTab, setActiveTab] = useState("transactions");
|
||||||
|
const [isContract, setIsContract] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkIsContract = async () => {
|
||||||
|
const contractCode = await publicClient.getBytecode({ address: address });
|
||||||
|
setIsContract(contractCode !== undefined && contractCode !== "0x");
|
||||||
|
};
|
||||||
|
|
||||||
|
checkIsContract();
|
||||||
|
}, [address]);
|
||||||
|
|
||||||
|
const filteredBlocks = blocks.filter(block =>
|
||||||
|
block.transactions.some(tx => {
|
||||||
|
if (typeof tx === "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return tx.from.toLowerCase() === address.toLowerCase() || tx.to?.toLowerCase() === address.toLowerCase();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isContract && (
|
||||||
|
<div role="tablist" className="tabs tabs-lift">
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
className={`tab ${activeTab === "transactions" ? "tab-active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("transactions")}
|
||||||
|
>
|
||||||
|
Transactions
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
className={`tab ${activeTab === "code" ? "tab-active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("code")}
|
||||||
|
>
|
||||||
|
Code
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
className={`tab ${activeTab === "storage" ? "tab-active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("storage")}
|
||||||
|
>
|
||||||
|
Storage
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
className={`tab ${activeTab === "logs" ? "tab-active" : ""}`}
|
||||||
|
onClick={() => setActiveTab("logs")}
|
||||||
|
>
|
||||||
|
Logs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === "transactions" && (
|
||||||
|
<div className="pt-4">
|
||||||
|
<TransactionsTable blocks={filteredBlocks} transactionReceipts={transactionReceipts} />
|
||||||
|
<PaginationButton
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalItems={Number(totalBlocks)}
|
||||||
|
setCurrentPage={setCurrentPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{activeTab === "code" && contractData && (
|
||||||
|
<AddressCodeTab bytecode={contractData.bytecode} assembly={contractData.assembly} />
|
||||||
|
)}
|
||||||
|
{activeTab === "storage" && <AddressStorageTab address={address} />}
|
||||||
|
{activeTab === "logs" && <AddressLogsTab address={address} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
type PaginationButtonProps = {
|
||||||
|
currentPage: number;
|
||||||
|
totalItems: number;
|
||||||
|
setCurrentPage: (page: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 20;
|
||||||
|
|
||||||
|
export const PaginationButton = ({ currentPage, totalItems, setCurrentPage }: PaginationButtonProps) => {
|
||||||
|
const isPrevButtonDisabled = currentPage === 0;
|
||||||
|
const isNextButtonDisabled = currentPage + 1 >= Math.ceil(totalItems / ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
const prevButtonClass = isPrevButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
|
||||||
|
const nextButtonClass = isNextButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
|
||||||
|
|
||||||
|
if (isNextButtonDisabled && isPrevButtonDisabled) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-5 justify-end flex gap-3 mx-5">
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${prevButtonClass}`}
|
||||||
|
disabled={isPrevButtonDisabled}
|
||||||
|
onClick={() => setCurrentPage(currentPage - 1)}
|
||||||
|
>
|
||||||
|
<ArrowLeftIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<span className="self-center text-primary-content font-medium">Page {currentPage + 1}</span>
|
||||||
|
<button
|
||||||
|
className={`btn btn-sm ${nextButtonClass}`}
|
||||||
|
disabled={isNextButtonDisabled}
|
||||||
|
onClick={() => setCurrentPage(currentPage + 1)}
|
||||||
|
>
|
||||||
|
<ArrowRightIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
49
packages/nextjs/app/blockexplorer/_components/SearchBar.tsx
Normal file
49
packages/nextjs/app/blockexplorer/_components/SearchBar.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { isAddress, isHex } from "viem";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { usePublicClient } from "wagmi";
|
||||||
|
|
||||||
|
export const SearchBar = () => {
|
||||||
|
const [searchInput, setSearchInput] = useState("");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const client = usePublicClient({ chainId: hardhat.id });
|
||||||
|
|
||||||
|
const handleSearch = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (isHex(searchInput)) {
|
||||||
|
try {
|
||||||
|
const tx = await client?.getTransaction({ hash: searchInput });
|
||||||
|
if (tx) {
|
||||||
|
router.push(`/blockexplorer/transaction/${searchInput}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch transaction:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isAddress(searchInput)) {
|
||||||
|
router.push(`/blockexplorer/address/${searchInput}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSearch} className="flex items-center justify-end mb-5 space-x-3 mx-5">
|
||||||
|
<input
|
||||||
|
className="border-primary bg-base-100 text-base-content placeholder:text-base-content/50 p-2 mr-2 w-full md:w-1/2 lg:w-1/3 rounded-md shadow-md focus:outline-hidden focus:ring-2 focus:ring-accent"
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
placeholder="Search by hash or address"
|
||||||
|
onChange={e => setSearchInput(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button className="btn btn-sm btn-primary" type="submit">
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { useCopyToClipboard } from "~~/hooks/scaffold-eth/useCopyToClipboard";
|
||||||
|
|
||||||
|
export const TransactionHash = ({ hash }: { hash: string }) => {
|
||||||
|
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
|
||||||
|
useCopyToClipboard();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Link href={`/blockexplorer/transaction/${hash}`}>
|
||||||
|
{hash?.substring(0, 6)}...{hash?.substring(hash.length - 4)}
|
||||||
|
</Link>
|
||||||
|
{isAddressCopiedToClipboard ? (
|
||||||
|
<CheckCircleIcon
|
||||||
|
className="ml-1.5 text-xl font-normal text-base-content h-5 w-5 cursor-pointer"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<DocumentDuplicateIcon
|
||||||
|
className="ml-1.5 text-xl font-normal h-5 w-5 cursor-pointer"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={() => copyAddressToClipboard(hash)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { TransactionHash } from "./TransactionHash";
|
||||||
|
import { Address } from "@scaffold-ui/components";
|
||||||
|
import { formatEther } from "viem";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||||
|
import { TransactionWithFunction } from "~~/utils/scaffold-eth";
|
||||||
|
import { TransactionsTableProps } from "~~/utils/scaffold-eth/";
|
||||||
|
|
||||||
|
export const TransactionsTable = ({ blocks, transactionReceipts }: TransactionsTableProps) => {
|
||||||
|
const { targetNetwork } = useTargetNetwork();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center px-4 md:px-0">
|
||||||
|
<div className="overflow-x-auto w-full shadow-2xl rounded-xl">
|
||||||
|
<table className="table text-xl bg-base-100 table-zebra w-full md:table-md table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="rounded-xl text-sm text-base-content">
|
||||||
|
<th className="bg-primary">Transaction Hash</th>
|
||||||
|
<th className="bg-primary">Function Called</th>
|
||||||
|
<th className="bg-primary">Block Number</th>
|
||||||
|
<th className="bg-primary">Time Mined</th>
|
||||||
|
<th className="bg-primary">From</th>
|
||||||
|
<th className="bg-primary">To</th>
|
||||||
|
<th className="bg-primary text-end">Value ({targetNetwork.nativeCurrency.symbol})</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{blocks.map(block =>
|
||||||
|
(block.transactions as TransactionWithFunction[]).map(tx => {
|
||||||
|
const receipt = transactionReceipts[tx.hash];
|
||||||
|
const timeMined = new Date(Number(block.timestamp) * 1000).toLocaleString();
|
||||||
|
const functionCalled = tx.input.substring(0, 10);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={tx.hash} className="hover text-sm">
|
||||||
|
<td className="w-1/12 md:py-4">
|
||||||
|
<TransactionHash hash={tx.hash} />
|
||||||
|
</td>
|
||||||
|
<td className="w-2/12 md:py-4">
|
||||||
|
{tx.functionName === "0x" ? "" : <span className="mr-1">{tx.functionName}</span>}
|
||||||
|
{functionCalled !== "0x" && (
|
||||||
|
<span className="badge badge-primary font-bold text-xs">{functionCalled}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="w-1/12 md:py-4">{block.number?.toString()}</td>
|
||||||
|
<td className="w-2/12 md:py-4">{timeMined}</td>
|
||||||
|
<td className="w-2/12 md:py-4">
|
||||||
|
<Address
|
||||||
|
address={tx.from}
|
||||||
|
size="sm"
|
||||||
|
onlyEnsOrAddress
|
||||||
|
blockExplorerAddressLink={
|
||||||
|
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${tx.from}` : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="w-2/12 md:py-4">
|
||||||
|
{!receipt?.contractAddress ? (
|
||||||
|
tx.to && (
|
||||||
|
<Address
|
||||||
|
address={tx.to}
|
||||||
|
size="sm"
|
||||||
|
onlyEnsOrAddress
|
||||||
|
blockExplorerAddressLink={
|
||||||
|
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${tx.to}` : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="relative">
|
||||||
|
<Address
|
||||||
|
address={receipt.contractAddress}
|
||||||
|
size="sm"
|
||||||
|
onlyEnsOrAddress
|
||||||
|
blockExplorerAddressLink={
|
||||||
|
targetNetwork.id === hardhat.id
|
||||||
|
? `/blockexplorer/address/${receipt.contractAddress}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<small className="absolute top-4 left-4">(Contract Creation)</small>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-right md:py-4">
|
||||||
|
{formatEther(tx.value)} {targetNetwork.nativeCurrency.symbol}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
7
packages/nextjs/app/blockexplorer/_components/index.tsx
Normal file
7
packages/nextjs/app/blockexplorer/_components/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export * from "./SearchBar";
|
||||||
|
export * from "./BackButton";
|
||||||
|
export * from "./AddressCodeTab";
|
||||||
|
export * from "./TransactionHash";
|
||||||
|
export * from "./ContractTabs";
|
||||||
|
export * from "./PaginationButton";
|
||||||
|
export * from "./TransactionsTable";
|
||||||
101
packages/nextjs/app/blockexplorer/address/[address]/page.tsx
Normal file
101
packages/nextjs/app/blockexplorer/address/[address]/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
import { Address } from "viem";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { AddressComponent } from "~~/app/blockexplorer/_components/AddressComponent";
|
||||||
|
import deployedContracts from "~~/contracts/deployedContracts";
|
||||||
|
import { isZeroAddress } from "~~/utils/scaffold-eth/common";
|
||||||
|
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<{ address: Address }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchByteCodeAndAssembly(buildInfoDirectory: string, contractPath: string) {
|
||||||
|
const buildInfoFiles = fs.readdirSync(buildInfoDirectory);
|
||||||
|
let bytecode = "";
|
||||||
|
let assembly = "";
|
||||||
|
|
||||||
|
for (let i = 0; i < buildInfoFiles.length; i++) {
|
||||||
|
const filePath = path.join(buildInfoDirectory, buildInfoFiles[i]);
|
||||||
|
|
||||||
|
const buildInfo = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||||
|
|
||||||
|
if (buildInfo.output.contracts[contractPath]) {
|
||||||
|
for (const contract in buildInfo.output.contracts[contractPath]) {
|
||||||
|
bytecode = buildInfo.output.contracts[contractPath][contract].evm.bytecode.object;
|
||||||
|
assembly = buildInfo.output.contracts[contractPath][contract].evm.bytecode.opcodes;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bytecode && assembly) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { bytecode, assembly };
|
||||||
|
}
|
||||||
|
|
||||||
|
const getContractData = async (address: Address) => {
|
||||||
|
const contracts = deployedContracts as GenericContractsDeclaration | null;
|
||||||
|
const chainId = hardhat.id;
|
||||||
|
|
||||||
|
if (!contracts || !contracts[chainId] || Object.keys(contracts[chainId]).length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contractPath = "";
|
||||||
|
|
||||||
|
const buildInfoDirectory = path.join(
|
||||||
|
__dirname,
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"..",
|
||||||
|
"hardhat",
|
||||||
|
"artifacts",
|
||||||
|
"build-info",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(buildInfoDirectory)) {
|
||||||
|
throw new Error(`Directory ${buildInfoDirectory} not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deployedContractsOnChain = contracts[chainId];
|
||||||
|
for (const [contractName, contractInfo] of Object.entries(deployedContractsOnChain)) {
|
||||||
|
if (contractInfo.address.toLowerCase() === address.toLowerCase()) {
|
||||||
|
contractPath = `contracts/${contractName}.sol`;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contractPath) {
|
||||||
|
// No contract found at this address
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bytecode, assembly } = await fetchByteCodeAndAssembly(buildInfoDirectory, contractPath);
|
||||||
|
|
||||||
|
return { bytecode, assembly };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
// An workaround to enable static exports in Next.js, generating single dummy page.
|
||||||
|
return [{ address: "0x0000000000000000000000000000000000000000" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddressPage = async (props: PageProps) => {
|
||||||
|
const params = await props.params;
|
||||||
|
const address = params?.address as Address;
|
||||||
|
|
||||||
|
if (isZeroAddress(address)) return null;
|
||||||
|
|
||||||
|
const contractData: { bytecode: string; assembly: string } | null = await getContractData(address);
|
||||||
|
return <AddressComponent address={address} contractData={contractData} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddressPage;
|
||||||
12
packages/nextjs/app/blockexplorer/layout.tsx
Normal file
12
packages/nextjs/app/blockexplorer/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
|
||||||
|
|
||||||
|
export const metadata = getMetadata({
|
||||||
|
title: "Block Explorer",
|
||||||
|
description: "Block Explorer created with 🏗 Scaffold-ETH 2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const BlockExplorerLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return <>{children}</>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlockExplorerLayout;
|
||||||
83
packages/nextjs/app/blockexplorer/page.tsx
Normal file
83
packages/nextjs/app/blockexplorer/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { PaginationButton, SearchBar, TransactionsTable } from "./_components";
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { useFetchBlocks } from "~~/hooks/scaffold-eth";
|
||||||
|
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||||
|
import { notification } from "~~/utils/scaffold-eth";
|
||||||
|
|
||||||
|
const BlockExplorer: NextPage = () => {
|
||||||
|
const { blocks, transactionReceipts, currentPage, totalBlocks, setCurrentPage, error } = useFetchBlocks();
|
||||||
|
const { targetNetwork } = useTargetNetwork();
|
||||||
|
const [isLocalNetwork, setIsLocalNetwork] = useState(true);
|
||||||
|
const [hasError, setHasError] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetNetwork.id !== hardhat.id) {
|
||||||
|
setIsLocalNetwork(false);
|
||||||
|
}
|
||||||
|
}, [targetNetwork.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (targetNetwork.id === hardhat.id && error) {
|
||||||
|
setHasError(true);
|
||||||
|
}
|
||||||
|
}, [targetNetwork.id, error]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLocalNetwork) {
|
||||||
|
notification.error(
|
||||||
|
<>
|
||||||
|
<p className="font-bold mt-0 mb-1">
|
||||||
|
<code className="italic bg-base-300 text-base font-bold"> targetNetwork </code> is not localhost
|
||||||
|
</p>
|
||||||
|
<p className="m-0">
|
||||||
|
- You are on <code className="italic bg-base-300 text-base font-bold">{targetNetwork.name}</code> .This
|
||||||
|
block explorer is only for <code className="italic bg-base-300 text-base font-bold">localhost</code>.
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-normal">
|
||||||
|
- You can use{" "}
|
||||||
|
<a className="text-accent" href={targetNetwork.blockExplorers?.default.url}>
|
||||||
|
{targetNetwork.blockExplorers?.default.name}
|
||||||
|
</a>{" "}
|
||||||
|
instead
|
||||||
|
</p>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
isLocalNetwork,
|
||||||
|
targetNetwork.blockExplorers?.default.name,
|
||||||
|
targetNetwork.blockExplorers?.default.url,
|
||||||
|
targetNetwork.name,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasError) {
|
||||||
|
notification.error(
|
||||||
|
<>
|
||||||
|
<p className="font-bold mt-0 mb-1">Cannot connect to local provider</p>
|
||||||
|
<p className="m-0">
|
||||||
|
- Did you forget to run <code className="italic bg-base-300 text-base font-bold">yarn chain</code> ?
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-normal">
|
||||||
|
- Or you can change <code className="italic bg-base-300 text-base font-bold">targetNetwork</code> in{" "}
|
||||||
|
<code className="italic bg-base-300 text-base font-bold">scaffold.config.ts</code>
|
||||||
|
</p>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [hasError]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto my-10">
|
||||||
|
<SearchBar />
|
||||||
|
<TransactionsTable blocks={blocks} transactionReceipts={transactionReceipts} />
|
||||||
|
<PaginationButton currentPage={currentPage} totalItems={Number(totalBlocks)} setCurrentPage={setCurrentPage} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BlockExplorer;
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import TransactionComp from "../_components/TransactionComp";
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import { Hash } from "viem";
|
||||||
|
import { isZeroAddress } from "~~/utils/scaffold-eth/common";
|
||||||
|
|
||||||
|
type PageProps = {
|
||||||
|
params: Promise<{ txHash?: Hash }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function generateStaticParams() {
|
||||||
|
// An workaround to enable static exports in Next.js, generating single dummy page.
|
||||||
|
return [{ txHash: "0x0000000000000000000000000000000000000000" }];
|
||||||
|
}
|
||||||
|
const TransactionPage: NextPage<PageProps> = async (props: PageProps) => {
|
||||||
|
const params = await props.params;
|
||||||
|
const txHash = params?.txHash as Hash;
|
||||||
|
|
||||||
|
if (isZeroAddress(txHash)) return null;
|
||||||
|
|
||||||
|
return <TransactionComp txHash={txHash} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TransactionPage;
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Address } from "@scaffold-ui/components";
|
||||||
|
import { Hash, Transaction, TransactionReceipt, formatEther, formatUnits } from "viem";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { usePublicClient } from "wagmi";
|
||||||
|
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||||
|
import { decodeTransactionData, getFunctionDetails } from "~~/utils/scaffold-eth";
|
||||||
|
import { replacer } from "~~/utils/scaffold-eth/common";
|
||||||
|
|
||||||
|
const TransactionComp = ({ txHash }: { txHash: Hash }) => {
|
||||||
|
const client = usePublicClient({ chainId: hardhat.id });
|
||||||
|
const router = useRouter();
|
||||||
|
const [transaction, setTransaction] = useState<Transaction>();
|
||||||
|
const [receipt, setReceipt] = useState<TransactionReceipt>();
|
||||||
|
const [functionCalled, setFunctionCalled] = useState<string>();
|
||||||
|
|
||||||
|
const { targetNetwork } = useTargetNetwork();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (txHash && client) {
|
||||||
|
const fetchTransaction = async () => {
|
||||||
|
const tx = await client.getTransaction({ hash: txHash });
|
||||||
|
const receipt = await client.getTransactionReceipt({ hash: txHash });
|
||||||
|
|
||||||
|
const transactionWithDecodedData = decodeTransactionData(tx);
|
||||||
|
setTransaction(transactionWithDecodedData);
|
||||||
|
setReceipt(receipt);
|
||||||
|
|
||||||
|
const functionCalled = transactionWithDecodedData.input.substring(0, 10);
|
||||||
|
setFunctionCalled(functionCalled);
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTransaction();
|
||||||
|
}
|
||||||
|
}, [client, txHash]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto mt-10 mb-20 px-10 md:px-0">
|
||||||
|
<button className="btn btn-sm btn-primary" onClick={() => router.back()}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
{transaction ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<h2 className="text-3xl font-bold mb-4 text-center text-primary-content">Transaction Details</h2>{" "}
|
||||||
|
<table className="table rounded-lg bg-base-100 w-full shadow-lg md:table-lg table-md">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>Transaction Hash:</strong>
|
||||||
|
</td>
|
||||||
|
<td>{transaction.hash}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>Block Number:</strong>
|
||||||
|
</td>
|
||||||
|
<td>{Number(transaction.blockNumber)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>From:</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<Address
|
||||||
|
address={transaction.from}
|
||||||
|
format="long"
|
||||||
|
onlyEnsOrAddress
|
||||||
|
blockExplorerAddressLink={
|
||||||
|
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${transaction.from}` : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>To:</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{!receipt?.contractAddress ? (
|
||||||
|
transaction.to && (
|
||||||
|
<Address
|
||||||
|
address={transaction.to}
|
||||||
|
format="long"
|
||||||
|
onlyEnsOrAddress
|
||||||
|
blockExplorerAddressLink={
|
||||||
|
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${transaction.to}` : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
Contract Creation:
|
||||||
|
<Address
|
||||||
|
address={receipt.contractAddress}
|
||||||
|
format="long"
|
||||||
|
onlyEnsOrAddress
|
||||||
|
blockExplorerAddressLink={
|
||||||
|
targetNetwork.id === hardhat.id
|
||||||
|
? `/blockexplorer/address/${receipt.contractAddress}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>Value:</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{formatEther(transaction.value)} {targetNetwork.nativeCurrency.symbol}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>Function called:</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="w-full md:max-w-[600px] lg:max-w-[800px] overflow-x-auto whitespace-nowrap">
|
||||||
|
{functionCalled === "0x" ? (
|
||||||
|
"This transaction did not call any function."
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="mr-2">{getFunctionDetails(transaction)}</span>
|
||||||
|
<span className="badge badge-primary font-bold">{functionCalled}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>Gas Price:</strong>
|
||||||
|
</td>
|
||||||
|
<td>{formatUnits(transaction.gasPrice || 0n, 9)} Gwei</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>Data:</strong>
|
||||||
|
</td>
|
||||||
|
<td className="form-control">
|
||||||
|
<textarea
|
||||||
|
readOnly
|
||||||
|
value={transaction.input}
|
||||||
|
className="p-0 w-full textarea-primary bg-inherit h-[150px]"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<strong>Logs:</strong>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<ul>
|
||||||
|
{receipt?.logs?.map((log, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
<strong>Log {i} topics:</strong> {JSON.stringify(log.topics, replacer, 2)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-2xl text-base-content">Loading...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TransactionComp;
|
||||||
38
packages/nextjs/app/debug/_components/ContractUI.tsx
Normal file
38
packages/nextjs/app/debug/_components/ContractUI.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// @refresh reset
|
||||||
|
import { Contract } from "@scaffold-ui/debug-contracts";
|
||||||
|
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
|
||||||
|
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||||
|
import { ContractName } from "~~/utils/scaffold-eth/contract";
|
||||||
|
|
||||||
|
type ContractUIProps = {
|
||||||
|
contractName: ContractName;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UI component to interface with deployed contracts.
|
||||||
|
**/
|
||||||
|
export const ContractUI = ({ contractName }: ContractUIProps) => {
|
||||||
|
const { targetNetwork } = useTargetNetwork();
|
||||||
|
const { data: deployedContractData, isLoading: deployedContractLoading } = useDeployedContractInfo({ contractName });
|
||||||
|
|
||||||
|
if (deployedContractLoading) {
|
||||||
|
return (
|
||||||
|
<div className="mt-14">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!deployedContractData) {
|
||||||
|
return (
|
||||||
|
<p className="text-3xl mt-14">
|
||||||
|
No contract found by the name of {contractName} on chain {targetNetwork.name}!
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Contract contractName={contractName as string} contract={deployedContractData} chainId={targetNetwork.id} />;
|
||||||
|
};
|
||||||
71
packages/nextjs/app/debug/_components/DebugContracts.tsx
Normal file
71
packages/nextjs/app/debug/_components/DebugContracts.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { ContractUI } from "./ContractUI";
|
||||||
|
import "@scaffold-ui/debug-contracts/styles.css";
|
||||||
|
import { useSessionStorage } from "usehooks-ts";
|
||||||
|
import { BarsArrowUpIcon } from "@heroicons/react/20/solid";
|
||||||
|
import { ContractName, GenericContract } from "~~/utils/scaffold-eth/contract";
|
||||||
|
import { useAllContracts } from "~~/utils/scaffold-eth/contractsData";
|
||||||
|
|
||||||
|
const selectedContractStorageKey = "scaffoldEth2.selectedContract";
|
||||||
|
|
||||||
|
export function DebugContracts() {
|
||||||
|
const contractsData = useAllContracts();
|
||||||
|
const contractNames = useMemo(
|
||||||
|
() =>
|
||||||
|
Object.keys(contractsData).sort((a, b) => {
|
||||||
|
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
||||||
|
}) as ContractName[],
|
||||||
|
[contractsData],
|
||||||
|
);
|
||||||
|
|
||||||
|
const [selectedContract, setSelectedContract] = useSessionStorage<ContractName>(
|
||||||
|
selectedContractStorageKey,
|
||||||
|
contractNames[0],
|
||||||
|
{ initializeWithValue: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!contractNames.includes(selectedContract)) {
|
||||||
|
setSelectedContract(contractNames[0]);
|
||||||
|
}
|
||||||
|
}, [contractNames, selectedContract, setSelectedContract]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-y-6 lg:gap-y-8 py-8 lg:py-12 justify-center items-center">
|
||||||
|
{contractNames.length === 0 ? (
|
||||||
|
<p className="text-3xl mt-14">No contracts found!</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{contractNames.length > 1 && (
|
||||||
|
<div className="flex flex-row gap-2 w-full max-w-7xl pb-1 px-6 lg:px-10 flex-wrap">
|
||||||
|
{contractNames.map(contractName => (
|
||||||
|
<button
|
||||||
|
className={`btn btn-secondary btn-sm font-light hover:border-transparent ${
|
||||||
|
contractName === selectedContract
|
||||||
|
? "bg-base-300 hover:bg-base-300 no-animation"
|
||||||
|
: "bg-base-100 hover:bg-secondary"
|
||||||
|
}`}
|
||||||
|
key={contractName}
|
||||||
|
onClick={() => setSelectedContract(contractName)}
|
||||||
|
>
|
||||||
|
{contractName}
|
||||||
|
{(contractsData[contractName] as GenericContract)?.external && (
|
||||||
|
<span className="tooltip tooltip-top tooltip-accent" data-tip="External contract">
|
||||||
|
<BarsArrowUpIcon className="h-4 w-4 cursor-pointer" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{contractNames.map(
|
||||||
|
contractName =>
|
||||||
|
contractName === selectedContract && <ContractUI key={contractName} contractName={contractName} />,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
packages/nextjs/app/debug/page.tsx
Normal file
28
packages/nextjs/app/debug/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { DebugContracts } from "./_components/DebugContracts";
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
|
||||||
|
|
||||||
|
export const metadata = getMetadata({
|
||||||
|
title: "Debug Contracts",
|
||||||
|
description: "Debug your deployed 🏗 Scaffold-ETH 2 contracts in an easy way",
|
||||||
|
});
|
||||||
|
|
||||||
|
const Debug: NextPage = () => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DebugContracts />
|
||||||
|
<div className="text-center mt-8 bg-secondary p-10">
|
||||||
|
<h1 className="text-4xl my-0">Debug Contracts</h1>
|
||||||
|
<p className="text-neutral">
|
||||||
|
You can debug & interact with your deployed contracts here.
|
||||||
|
<br /> Check{" "}
|
||||||
|
<code className="italic bg-base-300 text-base font-bold [word-spacing:-0.5rem] px-1">
|
||||||
|
packages / nextjs / app / debug / page.tsx
|
||||||
|
</code>{" "}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Debug;
|
||||||
116
packages/nextjs/app/events/page.tsx
Normal file
116
packages/nextjs/app/events/page.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Address } from "@scaffold-ui/components";
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import { formatEther } from "viem";
|
||||||
|
import { useScaffoldEventHistory } from "~~/hooks/scaffold-eth";
|
||||||
|
|
||||||
|
const Events: NextPage = () => {
|
||||||
|
// BuyTokens Events
|
||||||
|
const { data: buyTokenEvents, isLoading: isBuyEventsLoading } = useScaffoldEventHistory({
|
||||||
|
contractName: "Vendor",
|
||||||
|
eventName: "BuyTokens",
|
||||||
|
});
|
||||||
|
|
||||||
|
// // SellTokens Events
|
||||||
|
// const { data: sellTokenEvents, isLoading: isSellEventsLoading } = useScaffoldEventHistory({
|
||||||
|
// contractName: "Vendor",
|
||||||
|
// eventName: "SellTokens",
|
||||||
|
// });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center flex-col flex-grow pt-10">
|
||||||
|
{/* BuyTokens Events */}
|
||||||
|
<div>
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<span className="block text-2xl font-bold">Buy Token Events</span>
|
||||||
|
</div>
|
||||||
|
{isBuyEventsLoading ? (
|
||||||
|
<div className="flex justify-center items-center mt-8">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto shadow-lg">
|
||||||
|
<table className="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="bg-primary">Buyer</th>
|
||||||
|
<th className="bg-primary">Amount of Tokens</th>
|
||||||
|
<th className="bg-primary">Amount of ETH</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{!buyTokenEvents || buyTokenEvents.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-center">
|
||||||
|
No events found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
buyTokenEvents?.map((event, index) => {
|
||||||
|
return (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="text-center">
|
||||||
|
<Address address={event.args?.buyer} />
|
||||||
|
</td>
|
||||||
|
<td>{formatEther(event.args?.amountOfTokens || 0n)}</td>
|
||||||
|
<td>{formatEther(event.args?.amountOfETH || 0n)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* SellTokens Events */}
|
||||||
|
{/* <div className="mt-14">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<span className="block text-2xl font-bold">Sell Token Events</span>
|
||||||
|
</div>
|
||||||
|
{isSellEventsLoading ? (
|
||||||
|
<div className="flex justify-center items-center mt-8">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto shadow-lg">
|
||||||
|
<table className="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="bg-primary">Seller</th>
|
||||||
|
<th className="bg-primary">Amount of Tokens</th>
|
||||||
|
<th className="bg-primary">Amount of ETH</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{!sellTokenEvents || sellTokenEvents.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="text-center">
|
||||||
|
No events found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
sellTokenEvents?.map((event, index) => {
|
||||||
|
return (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="text-center">
|
||||||
|
<Address address={event.args.seller} />
|
||||||
|
</td>
|
||||||
|
<td>{formatEther(event.args?.amountOfTokens || 0n)}</td>
|
||||||
|
<td>{formatEther(event.args?.amountOfETH || 0n)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Events;
|
||||||
31
packages/nextjs/app/layout.tsx
Normal file
31
packages/nextjs/app/layout.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Space_Grotesk } from "next/font/google";
|
||||||
|
import "@rainbow-me/rainbowkit/styles.css";
|
||||||
|
import "@scaffold-ui/components/styles.css";
|
||||||
|
import { ScaffoldEthAppWithProviders } from "~~/components/ScaffoldEthAppWithProviders";
|
||||||
|
import { ThemeProvider } from "~~/components/ThemeProvider";
|
||||||
|
import "~~/styles/globals.css";
|
||||||
|
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
|
||||||
|
|
||||||
|
const spaceGrotesk = Space_Grotesk({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-space-grotesk",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const metadata = getMetadata({
|
||||||
|
title: "Token Vendor | Speedrun Ethereum",
|
||||||
|
description: "Built with 🏗 Scaffold-ETH 2",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<html suppressHydrationWarning className={`${spaceGrotesk.variable} font-space-grotesk`}>
|
||||||
|
<body>
|
||||||
|
<ThemeProvider enableSystem>
|
||||||
|
<ScaffoldEthAppWithProviders>{children}</ScaffoldEthAppWithProviders>
|
||||||
|
</ThemeProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ScaffoldEthApp;
|
||||||
16
packages/nextjs/app/not-found.tsx
Normal file
16
packages/nextjs/app/not-found.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center h-full flex-1 justify-center bg-base-200">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-6xl font-bold m-0 mb-1">404</h1>
|
||||||
|
<h2 className="text-2xl font-semibold m-0">Page Not Found</h2>
|
||||||
|
<p className="text-base-content/70 m-0 mb-4">The page you're looking for doesn't exist.</p>
|
||||||
|
<Link href="/" className="btn btn-primary">
|
||||||
|
Go Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
packages/nextjs/app/page.tsx
Normal file
101
packages/nextjs/app/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Address } from "@scaffold-ui/components";
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { useAccount } from "wagmi";
|
||||||
|
import { BugAntIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { useTargetNetwork } from "~~/hooks/scaffold-eth";
|
||||||
|
|
||||||
|
const Home: NextPage = () => {
|
||||||
|
const { address: connectedAddress } = useAccount();
|
||||||
|
const { targetNetwork } = useTargetNetwork();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center flex-col grow pt-10">
|
||||||
|
<div className="px-5">
|
||||||
|
<h1 className="text-center">
|
||||||
|
<span className="block text-2xl mb-2">Welcome to</span>
|
||||||
|
<span className="block text-4xl font-bold">Scaffold-ETH 2</span>
|
||||||
|
<span className="block text-xl font-bold">(Speedrun Ethereum Challenge: Token Vendor extension)</span>
|
||||||
|
</h1>
|
||||||
|
<div className="flex justify-center items-center space-x-2 flex-col">
|
||||||
|
<p className="my-2 font-medium">Connected Address:</p>
|
||||||
|
<Address
|
||||||
|
address={connectedAddress}
|
||||||
|
chain={targetNetwork}
|
||||||
|
blockExplorerAddressLink={
|
||||||
|
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${connectedAddress}` : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center flex-col flex-grow pt-10">
|
||||||
|
<div className="px-5">
|
||||||
|
<h1 className="text-center mb-6">
|
||||||
|
<span className="block text-2xl mb-2">Speedrun Ethereum</span>
|
||||||
|
<span className="block text-4xl font-bold">Challenge: 🏵 Token Vendor 🤖</span>
|
||||||
|
</h1>
|
||||||
|
<div className="flex flex-col items-center justify-center">
|
||||||
|
<Image
|
||||||
|
src="/hero.png"
|
||||||
|
width="727"
|
||||||
|
height="231"
|
||||||
|
alt="challenge banner"
|
||||||
|
className="rounded-xl border-4 border-primary"
|
||||||
|
/>
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<p className="text-center text-lg mt-8">
|
||||||
|
🤖 Smart contracts are kind of like "always on" vending machines that anyone can access.
|
||||||
|
Let's make a decentralized, digital currency. Then, let's build an unstoppable vending
|
||||||
|
machine that will buy and sell the currency. We'll learn about the "approve" pattern
|
||||||
|
for ERC20s and how contract to contract interactions work.
|
||||||
|
</p>
|
||||||
|
<p className="text-center text-lg">
|
||||||
|
🌟 The final deliverable is an app that lets users purchase your ERC20 token, transfer it, and sell
|
||||||
|
it back to the vendor. Deploy your contracts on your public chain of choice and then deploy your app
|
||||||
|
to a public webserver. Submit the url on{" "}
|
||||||
|
<a href="https://speedrunethereum.com/" target="_blank" rel="noreferrer" className="underline">
|
||||||
|
SpeedrunEthereum.com
|
||||||
|
</a>{" "}
|
||||||
|
!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grow bg-base-300 w-full mt-16 px-8 py-12">
|
||||||
|
<div className="flex justify-center items-center gap-12 flex-col md:flex-row">
|
||||||
|
<div className="flex flex-col bg-base-100 px-10 py-10 text-center items-center max-w-xs rounded-3xl">
|
||||||
|
<BugAntIcon className="h-8 w-8 fill-secondary" />
|
||||||
|
<p>
|
||||||
|
Tinker with your smart contract using the{" "}
|
||||||
|
<Link href="/debug" passHref className="link">
|
||||||
|
Debug Contracts
|
||||||
|
</Link>{" "}
|
||||||
|
tab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col bg-base-100 px-10 py-10 text-center items-center max-w-xs rounded-3xl">
|
||||||
|
<MagnifyingGlassIcon className="h-8 w-8 fill-secondary" />
|
||||||
|
<p>
|
||||||
|
Explore your local transactions with the{" "}
|
||||||
|
<Link href="/blockexplorer" passHref className="link">
|
||||||
|
Block Explorer
|
||||||
|
</Link>{" "}
|
||||||
|
tab.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
||||||
189
packages/nextjs/app/token-vendor/page.tsx
Normal file
189
packages/nextjs/app/token-vendor/page.tsx
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { AddressInput } from "@scaffold-ui/components";
|
||||||
|
import { IntegerInput } from "@scaffold-ui/debug-contracts";
|
||||||
|
import { useWatchBalance } from "@scaffold-ui/hooks";
|
||||||
|
import type { NextPage } from "next";
|
||||||
|
import { formatEther } from "viem";
|
||||||
|
import { useAccount } from "wagmi";
|
||||||
|
import { useDeployedContractInfo, useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
|
||||||
|
import { getTokenPrice, multiplyTo1e18 } from "~~/utils/scaffold-eth/priceInWei";
|
||||||
|
|
||||||
|
const TokenVendor: NextPage = () => {
|
||||||
|
const [toAddress, setToAddress] = useState("");
|
||||||
|
const [tokensToSend, setTokensToSend] = useState("");
|
||||||
|
const [tokensToBuy, setTokensToBuy] = useState<string | bigint>("");
|
||||||
|
const [isApproved, setIsApproved] = useState(false);
|
||||||
|
const [tokensToSell, setTokensToSell] = useState<string>("");
|
||||||
|
|
||||||
|
const { address } = useAccount();
|
||||||
|
const { data: yourTokenSymbol } = useScaffoldReadContract({
|
||||||
|
contractName: "YourToken",
|
||||||
|
functionName: "symbol",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: yourTokenBalance } = useScaffoldReadContract({
|
||||||
|
contractName: "YourToken",
|
||||||
|
functionName: "balanceOf",
|
||||||
|
args: [address],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: vendorContractData } = useDeployedContractInfo({ contractName: "Vendor" });
|
||||||
|
const { writeContractAsync: writeVendorAsync } = useScaffoldWriteContract({ contractName: "Vendor" });
|
||||||
|
const { writeContractAsync: writeYourTokenAsync } = useScaffoldWriteContract({ contractName: "YourToken" });
|
||||||
|
|
||||||
|
// const { data: vendorTokenBalance } = useScaffoldReadContract({
|
||||||
|
// contractName: "YourToken",
|
||||||
|
// functionName: "balanceOf",
|
||||||
|
// args: [vendorContractData?.address],
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const { data: vendorEthBalance } = useWatchBalance({ address: vendorContractData?.address });
|
||||||
|
|
||||||
|
// const { data: tokensPerEth } = useScaffoldReadContract({
|
||||||
|
// contractName: "Vendor",
|
||||||
|
// functionName: "tokensPerEth",
|
||||||
|
// });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center flex-col flex-grow pt-10">
|
||||||
|
<div className="flex flex-col items-center bg-base-100 shadow-lg shadow-secondary border-8 border-secondary rounded-xl p-6 mt-24 w-full max-w-lg">
|
||||||
|
<div className="text-xl">
|
||||||
|
Your token balance:{" "}
|
||||||
|
<div className="inline-flex items-center justify-center">
|
||||||
|
{parseFloat(formatEther(yourTokenBalance || 0n)).toFixed(4)}
|
||||||
|
<span className="font-bold ml-1">{yourTokenSymbol}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Vendor Balances */}
|
||||||
|
{/* <hr className="w-full border-secondary my-3" />
|
||||||
|
<div>
|
||||||
|
Vendor token balance:{" "}
|
||||||
|
<div className="inline-flex items-center justify-center">
|
||||||
|
{Number(formatEther(vendorTokenBalance || 0n)).toFixed(4)}
|
||||||
|
<span className="font-bold ml-1">{yourTokenSymbol}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Vendor eth balance: {Number(formatEther(vendorEthBalance?.value || 0n)).toFixed(4)}
|
||||||
|
<span className="font-bold ml-1">ETH</span>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Buy Tokens */}
|
||||||
|
{/* <div className="flex flex-col items-center space-y-4 bg-base-100 shadow-lg shadow-secondary border-8 border-secondary rounded-xl p-6 mt-8 w-full max-w-lg">
|
||||||
|
<div className="text-xl">Buy tokens</div>
|
||||||
|
<div>{tokensPerEth?.toString() || 0} tokens per ETH</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col space-y-2">
|
||||||
|
<IntegerInput
|
||||||
|
placeholder="amount of tokens to buy"
|
||||||
|
value={tokensToBuy.toString()}
|
||||||
|
onChange={value => setTokensToBuy(value)}
|
||||||
|
disableMultiplyBy1e18
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary mt-2"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await writeVendorAsync({ functionName: "buyTokens", value: getTokenPrice(tokensToBuy, tokensPerEth) });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error calling buyTokens function", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Buy Tokens
|
||||||
|
</button>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{!!yourTokenBalance && (
|
||||||
|
<div className="flex flex-col items-center space-y-4 bg-base-100 shadow-lg shadow-secondary border-8 border-secondary rounded-xl p-6 mt-8 w-full max-w-lg">
|
||||||
|
<div className="text-xl">Transfer tokens</div>
|
||||||
|
<div className="w-full flex flex-col space-y-2">
|
||||||
|
<AddressInput placeholder="to address" value={toAddress} onChange={value => setToAddress(value)} />
|
||||||
|
<IntegerInput
|
||||||
|
placeholder="amount of tokens to send"
|
||||||
|
value={tokensToSend}
|
||||||
|
onChange={value => setTokensToSend(value as string)}
|
||||||
|
disableMultiplyBy1e18
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await writeYourTokenAsync({
|
||||||
|
functionName: "transfer",
|
||||||
|
args: [toAddress, multiplyTo1e18(tokensToSend)],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error calling transfer function", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Send Tokens
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sell Tokens */}
|
||||||
|
{/* {!!yourTokenBalance && (
|
||||||
|
<div className="flex flex-col items-center space-y-4 bg-base-100 shadow-lg shadow-secondary border-8 border-secondary rounded-xl p-6 mt-8 w-full max-w-lg">
|
||||||
|
<div className="text-xl">Sell tokens</div>
|
||||||
|
<div>{tokensPerEth?.toString() || 0} tokens per ETH</div>
|
||||||
|
|
||||||
|
<div className="w-full flex flex-col space-y-2">
|
||||||
|
<IntegerInput
|
||||||
|
placeholder="amount of tokens to sell"
|
||||||
|
value={tokensToSell}
|
||||||
|
onChange={value => setTokensToSell(value as string)}
|
||||||
|
disabled={isApproved}
|
||||||
|
disableMultiplyBy1e18
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<button
|
||||||
|
className={`btn ${isApproved ? "btn-disabled" : "btn-secondary"}`}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await writeYourTokenAsync({
|
||||||
|
functionName: "approve",
|
||||||
|
args: [vendorContractData?.address, multiplyTo1e18(tokensToSell)],
|
||||||
|
});
|
||||||
|
setIsApproved(true);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error calling approve function", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Approve Tokens
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btn ${isApproved ? "btn-secondary" : "btn-disabled"}`}
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await writeVendorAsync({ functionName: "sellTokens", args: [multiplyTo1e18(tokensToSell)] });
|
||||||
|
setIsApproved(false);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error calling sellTokens function", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sell Tokens
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)} */}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TokenVendor;
|
||||||
80
packages/nextjs/components/Footer.tsx
Normal file
80
packages/nextjs/components/Footer.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useFetchNativeCurrencyPrice } from "@scaffold-ui/hooks";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { CurrencyDollarIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { HeartIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { SwitchTheme } from "~~/components/SwitchTheme";
|
||||||
|
import { BuidlGuidlLogo } from "~~/components/assets/BuidlGuidlLogo";
|
||||||
|
import { Faucet } from "~~/components/scaffold-eth";
|
||||||
|
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site footer
|
||||||
|
*/
|
||||||
|
export const Footer = () => {
|
||||||
|
const { targetNetwork } = useTargetNetwork();
|
||||||
|
const isLocalNetwork = targetNetwork.id === hardhat.id;
|
||||||
|
const { price: nativeCurrencyPrice } = useFetchNativeCurrencyPrice();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-0 py-5 px-1 mb-11 lg:mb-0">
|
||||||
|
<div>
|
||||||
|
<div className="fixed flex justify-between items-center w-full z-10 p-4 bottom-0 left-0 pointer-events-none">
|
||||||
|
<div className="flex flex-col md:flex-row gap-2 pointer-events-auto">
|
||||||
|
{nativeCurrencyPrice > 0 && (
|
||||||
|
<div>
|
||||||
|
<div className="btn btn-primary btn-sm font-normal gap-1 cursor-auto">
|
||||||
|
<CurrencyDollarIcon className="h-4 w-4" />
|
||||||
|
<span>{nativeCurrencyPrice.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLocalNetwork && (
|
||||||
|
<>
|
||||||
|
<Faucet />
|
||||||
|
<Link href="/blockexplorer" passHref className="btn btn-primary btn-sm font-normal gap-1">
|
||||||
|
<MagnifyingGlassIcon className="h-4 w-4" />
|
||||||
|
<span>Block Explorer</span>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<SwitchTheme className={`pointer-events-auto ${isLocalNetwork ? "self-end md:self-auto" : ""}`} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full">
|
||||||
|
<ul className="menu menu-horizontal w-full">
|
||||||
|
<div className="flex justify-center items-center gap-2 text-sm w-full">
|
||||||
|
<div className="text-center">
|
||||||
|
<a href="https://github.com/scaffold-eth/se-2" target="_blank" rel="noreferrer" className="link">
|
||||||
|
Fork me
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<span>·</span>
|
||||||
|
<div className="flex justify-center items-center gap-2">
|
||||||
|
<p className="m-0 text-center">
|
||||||
|
Built with <HeartIcon className="inline-block h-4 w-4" /> at
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
className="flex justify-center items-center gap-1"
|
||||||
|
href="https://buidlguidl.com/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<BuidlGuidlLogo className="w-3 h-5 pb-1" />
|
||||||
|
<span className="link">BuidlGuidl</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<span>·</span>
|
||||||
|
<div className="text-center">
|
||||||
|
<a href="https://t.me/joinchat/KByvmRe5wkR-8F_zz6AjpA" target="_blank" rel="noreferrer" className="link">
|
||||||
|
Support
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
114
packages/nextjs/components/Header.tsx
Normal file
114
packages/nextjs/components/Header.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { useRef } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { Bars3Icon, BugAntIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { BoltIcon, CircleStackIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { FaucetButton, RainbowKitCustomConnectButton } from "~~/components/scaffold-eth";
|
||||||
|
import { useOutsideClick, useTargetNetwork } from "~~/hooks/scaffold-eth";
|
||||||
|
|
||||||
|
type HeaderMenuLink = {
|
||||||
|
label: string;
|
||||||
|
href: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const menuLinks: HeaderMenuLink[] = [
|
||||||
|
{
|
||||||
|
label: "Home",
|
||||||
|
href: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Token Vendor",
|
||||||
|
href: "/token-vendor",
|
||||||
|
icon: <CircleStackIcon className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Events",
|
||||||
|
href: "/events",
|
||||||
|
icon: <BoltIcon className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Debug Contracts",
|
||||||
|
href: "/debug",
|
||||||
|
icon: <BugAntIcon className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const HeaderMenuLinks = () => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{menuLinks.map(({ label, href, icon }) => {
|
||||||
|
const isActive = pathname === href;
|
||||||
|
return (
|
||||||
|
<li key={href}>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
passHref
|
||||||
|
className={`${
|
||||||
|
isActive ? "bg-secondary shadow-md" : ""
|
||||||
|
} hover:bg-secondary hover:shadow-md focus:!bg-secondary active:!text-neutral py-1.5 px-3 text-sm rounded-full gap-2 grid grid-flow-col`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site header
|
||||||
|
*/
|
||||||
|
export const Header = () => {
|
||||||
|
const { targetNetwork } = useTargetNetwork();
|
||||||
|
const isLocalNetwork = targetNetwork.id === hardhat.id;
|
||||||
|
|
||||||
|
const burgerMenuRef = useRef<HTMLDetailsElement>(null);
|
||||||
|
useOutsideClick(burgerMenuRef, () => {
|
||||||
|
burgerMenuRef?.current?.removeAttribute("open");
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="sticky lg:static top-0 navbar bg-base-100 min-h-0 shrink-0 justify-between z-20 shadow-md shadow-secondary px-0 sm:px-2">
|
||||||
|
<div className="navbar-start w-auto lg:w-1/2">
|
||||||
|
<details className="dropdown" ref={burgerMenuRef}>
|
||||||
|
<summary className="ml-1 btn btn-ghost lg:hidden hover:bg-transparent">
|
||||||
|
<Bars3Icon className="h-1/2" />
|
||||||
|
</summary>
|
||||||
|
<ul
|
||||||
|
className="menu menu-compact dropdown-content mt-3 p-2 shadow-sm bg-base-100 rounded-box w-52"
|
||||||
|
onClick={() => {
|
||||||
|
burgerMenuRef?.current?.removeAttribute("open");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HeaderMenuLinks />
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
<Link href="/" passHref className="hidden lg:flex items-center gap-2 ml-4 mr-6 shrink-0">
|
||||||
|
<div className="flex relative w-10 h-10">
|
||||||
|
<Image alt="SE2 logo" className="cursor-pointer" fill src="/logo.svg" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-bold leading-tight">SRE Challenges</span>
|
||||||
|
<span className="text-xs">Token Vendor</span>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
<ul className="hidden lg:flex lg:flex-nowrap menu menu-horizontal px-1 gap-2">
|
||||||
|
<HeaderMenuLinks />
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="navbar-end grow mr-4">
|
||||||
|
<RainbowKitCustomConnectButton />
|
||||||
|
{isLocalNetwork && <FaucetButton />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
58
packages/nextjs/components/ScaffoldEthAppWithProviders.tsx
Normal file
58
packages/nextjs/components/ScaffoldEthAppWithProviders.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { RainbowKitProvider, darkTheme, lightTheme } from "@rainbow-me/rainbowkit";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { AppProgressBar as ProgressBar } from "next-nprogress-bar";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Toaster } from "react-hot-toast";
|
||||||
|
import { WagmiProvider } from "wagmi";
|
||||||
|
import { Footer } from "~~/components/Footer";
|
||||||
|
import { Header } from "~~/components/Header";
|
||||||
|
import { BlockieAvatar } from "~~/components/scaffold-eth";
|
||||||
|
import { wagmiConfig } from "~~/services/web3/wagmiConfig";
|
||||||
|
|
||||||
|
const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className={`flex flex-col min-h-screen `}>
|
||||||
|
<Header />
|
||||||
|
<main className="relative flex flex-col flex-1">{children}</main>
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
<Toaster />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ScaffoldEthAppWithProviders = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const isDarkMode = resolvedTheme === "dark";
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WagmiProvider config={wagmiConfig}>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<RainbowKitProvider
|
||||||
|
avatar={BlockieAvatar}
|
||||||
|
theme={mounted ? (isDarkMode ? darkTheme() : lightTheme()) : lightTheme()}
|
||||||
|
>
|
||||||
|
<ProgressBar height="3px" color="#2299dd" />
|
||||||
|
<ScaffoldEthApp>{children}</ScaffoldEthApp>
|
||||||
|
</RainbowKitProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</WagmiProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
42
packages/nextjs/components/SwitchTheme.tsx
Normal file
42
packages/nextjs/components/SwitchTheme.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
export const SwitchTheme = ({ className }: { className?: string }) => {
|
||||||
|
const { setTheme, resolvedTheme } = useTheme();
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
const isDarkMode = resolvedTheme === "dark";
|
||||||
|
|
||||||
|
const handleToggle = () => {
|
||||||
|
if (isDarkMode) {
|
||||||
|
setTheme("light");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTheme("dark");
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!mounted) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex space-x-2 h-8 items-center justify-center text-sm ${className}`}>
|
||||||
|
<input
|
||||||
|
id="theme-toggle"
|
||||||
|
type="checkbox"
|
||||||
|
className="toggle bg-secondary toggle-primary hover:bg-accent transition-all"
|
||||||
|
onChange={handleToggle}
|
||||||
|
checked={isDarkMode}
|
||||||
|
/>
|
||||||
|
<label htmlFor="theme-toggle" className={`swap swap-rotate ${!isDarkMode ? "swap-active" : ""}`}>
|
||||||
|
<SunIcon className="swap-on h-5 w-5" />
|
||||||
|
<MoonIcon className="swap-off h-5 w-5" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
9
packages/nextjs/components/ThemeProvider.tsx
Normal file
9
packages/nextjs/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||||
|
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||||
|
|
||||||
|
export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
|
||||||
|
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||||
|
};
|
||||||
18
packages/nextjs/components/assets/BuidlGuidlLogo.tsx
Normal file
18
packages/nextjs/components/assets/BuidlGuidlLogo.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
export const BuidlGuidlLogo = ({ className }: { className: string }) => {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
width="53"
|
||||||
|
height="72"
|
||||||
|
viewBox="0 0 53 72"
|
||||||
|
fill="currentColor"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M25.9 17.434v15.638h3.927v9.04h9.718v-9.04h6.745v18.08l-10.607 19.88-12.11-.182-12.11.183L.856 51.152v-18.08h6.713v9.04h9.75v-9.04h4.329V2.46a2.126 2.126 0 0 1 4.047-.914c1.074.412 2.157 1.5 3.276 2.626 1.33 1.337 2.711 2.726 4.193 3.095 1.496.373 2.605-.026 3.855-.475 1.31-.47 2.776-.997 5.005-.747 1.67.197 2.557 1.289 3.548 2.509 1.317 1.623 2.82 3.473 6.599 3.752l-.024.017c-2.42 1.709-5.726 4.043-10.86 3.587-1.605-.139-2.736-.656-3.82-1.153-1.546-.707-2.997-1.37-5.59-.832-2.809.563-4.227 1.892-5.306 2.903-.236.221-.456.427-.67.606Z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
17
packages/nextjs/components/scaffold-eth/BlockieAvatar.tsx
Normal file
17
packages/nextjs/components/scaffold-eth/BlockieAvatar.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AvatarComponent } from "@rainbow-me/rainbowkit";
|
||||||
|
import { blo } from "blo";
|
||||||
|
|
||||||
|
// Custom Avatar for RainbowKit
|
||||||
|
export const BlockieAvatar: AvatarComponent = ({ address, ensImage, size }) => (
|
||||||
|
// Don't want to use nextJS Image here (and adding remote patterns for the URL)
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
className="rounded-full"
|
||||||
|
src={ensImage || blo(address as `0x${string}`)}
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
alt={`${address} avatar`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
140
packages/nextjs/components/scaffold-eth/Faucet.tsx
Normal file
140
packages/nextjs/components/scaffold-eth/Faucet.tsx
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Address, AddressInput, Balance, EtherInput } from "@scaffold-ui/components";
|
||||||
|
import { Address as AddressType, createWalletClient, http, parseEther } from "viem";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { useAccount } from "wagmi";
|
||||||
|
import { BanknotesIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { useTargetNetwork, useTransactor } from "~~/hooks/scaffold-eth";
|
||||||
|
import { notification } from "~~/utils/scaffold-eth";
|
||||||
|
|
||||||
|
// Account index to use from generated hardhat accounts.
|
||||||
|
const FAUCET_ACCOUNT_INDEX = 0;
|
||||||
|
|
||||||
|
const localWalletClient = createWalletClient({
|
||||||
|
chain: hardhat,
|
||||||
|
transport: http(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Faucet modal which lets you send ETH to any address.
|
||||||
|
*/
|
||||||
|
export const Faucet = () => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [inputAddress, setInputAddress] = useState<AddressType>();
|
||||||
|
const [faucetAddress, setFaucetAddress] = useState<AddressType>();
|
||||||
|
const [sendValue, setSendValue] = useState("");
|
||||||
|
const { targetNetwork } = useTargetNetwork();
|
||||||
|
|
||||||
|
const { chain: ConnectedChain } = useAccount();
|
||||||
|
|
||||||
|
const faucetTxn = useTransactor(localWalletClient);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getFaucetAddress = async () => {
|
||||||
|
try {
|
||||||
|
const accounts = await localWalletClient.getAddresses();
|
||||||
|
setFaucetAddress(accounts[FAUCET_ACCOUNT_INDEX]);
|
||||||
|
} catch (error) {
|
||||||
|
notification.error(
|
||||||
|
<>
|
||||||
|
<p className="font-bold mt-0 mb-1">Cannot connect to local provider</p>
|
||||||
|
<p className="m-0">
|
||||||
|
- Did you forget to run <code className="italic bg-base-300 text-base font-bold">yarn chain</code> ?
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 break-normal">
|
||||||
|
- Or you can change <code className="italic bg-base-300 text-base font-bold">targetNetwork</code> in{" "}
|
||||||
|
<code className="italic bg-base-300 text-base font-bold">scaffold.config.ts</code>
|
||||||
|
</p>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
console.error("⚡️ ~ file: Faucet.tsx:getFaucetAddress ~ error", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
getFaucetAddress();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sendETH = async () => {
|
||||||
|
if (!faucetAddress || !inputAddress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await faucetTxn({
|
||||||
|
to: inputAddress,
|
||||||
|
value: parseEther(sendValue as `${number}`),
|
||||||
|
account: faucetAddress,
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
setInputAddress(undefined);
|
||||||
|
setSendValue("");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("⚡️ ~ file: Faucet.tsx:sendETH ~ error", error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render only on local chain
|
||||||
|
if (ConnectedChain?.id !== hardhat.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label htmlFor="faucet-modal" className="btn btn-primary btn-sm font-normal gap-1">
|
||||||
|
<BanknotesIcon className="h-4 w-4" />
|
||||||
|
<span>Faucet</span>
|
||||||
|
</label>
|
||||||
|
<input type="checkbox" id="faucet-modal" className="modal-toggle" />
|
||||||
|
<label htmlFor="faucet-modal" className="modal cursor-pointer">
|
||||||
|
<label className="modal-box relative">
|
||||||
|
{/* dummy input to capture event onclick on modal box */}
|
||||||
|
<input className="h-0 w-0 absolute top-0 left-0" />
|
||||||
|
<h3 className="text-xl font-bold mb-3">Local Faucet</h3>
|
||||||
|
<label htmlFor="faucet-modal" className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
|
||||||
|
✕
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-bold">From:</span>
|
||||||
|
<Address
|
||||||
|
address={faucetAddress}
|
||||||
|
onlyEnsOrAddress
|
||||||
|
blockExplorerAddressLink={
|
||||||
|
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${faucetAddress}` : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-bold pl-3">Available:</span>
|
||||||
|
<Balance address={faucetAddress} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col space-y-3">
|
||||||
|
<AddressInput
|
||||||
|
placeholder="Destination Address"
|
||||||
|
value={inputAddress ?? ""}
|
||||||
|
onChange={value => setInputAddress(value as AddressType)}
|
||||||
|
/>
|
||||||
|
<EtherInput
|
||||||
|
placeholder="Amount to send"
|
||||||
|
onValueChange={({ valueInEth }) => setSendValue(valueInEth)}
|
||||||
|
style={{ width: "100%" }}
|
||||||
|
/>
|
||||||
|
<button className="h-10 btn btn-primary btn-sm px-2 rounded-full" onClick={sendETH} disabled={loading}>
|
||||||
|
{!loading ? (
|
||||||
|
<BanknotesIcon className="h-6 w-6" />
|
||||||
|
) : (
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
)}
|
||||||
|
<span>Send</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
73
packages/nextjs/components/scaffold-eth/FaucetButton.tsx
Normal file
73
packages/nextjs/components/scaffold-eth/FaucetButton.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useWatchBalance } from "@scaffold-ui/hooks";
|
||||||
|
import { createWalletClient, http, parseEther } from "viem";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { useAccount } from "wagmi";
|
||||||
|
import { BanknotesIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { useTransactor } from "~~/hooks/scaffold-eth";
|
||||||
|
|
||||||
|
// Number of ETH faucet sends to an address
|
||||||
|
const NUM_OF_ETH = "1";
|
||||||
|
const FAUCET_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
||||||
|
|
||||||
|
const localWalletClient = createWalletClient({
|
||||||
|
chain: hardhat,
|
||||||
|
transport: http(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FaucetButton button which lets you grab eth.
|
||||||
|
*/
|
||||||
|
export const FaucetButton = () => {
|
||||||
|
const { address, chain: ConnectedChain } = useAccount();
|
||||||
|
|
||||||
|
const { data: balance } = useWatchBalance({ address, chain: hardhat });
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const faucetTxn = useTransactor(localWalletClient);
|
||||||
|
|
||||||
|
const sendETH = async () => {
|
||||||
|
if (!address) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await faucetTxn({
|
||||||
|
account: FAUCET_ADDRESS,
|
||||||
|
to: address,
|
||||||
|
value: parseEther(NUM_OF_ETH),
|
||||||
|
});
|
||||||
|
setLoading(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("⚡️ ~ file: FaucetButton.tsx:sendETH ~ error", error);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render only on local chain
|
||||||
|
if (ConnectedChain?.id !== hardhat.id) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isBalanceZero = balance && balance.value === 0n;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
!isBalanceZero
|
||||||
|
? "ml-1"
|
||||||
|
: "ml-1 tooltip tooltip-bottom tooltip-primary tooltip-open font-bold before:left-auto before:transform-none before:content-[attr(data-tip)] before:-translate-x-2/5"
|
||||||
|
}
|
||||||
|
data-tip="Grab funds from faucet"
|
||||||
|
>
|
||||||
|
<button className="btn btn-secondary btn-sm px-2 rounded-full" onClick={sendETH} disabled={loading}>
|
||||||
|
{!loading ? (
|
||||||
|
<BanknotesIcon className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<span className="loading loading-spinner loading-xs"></span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { NetworkOptions } from "./NetworkOptions";
|
||||||
|
import { getAddress } from "viem";
|
||||||
|
import { Address } from "viem";
|
||||||
|
import { useAccount, useDisconnect } from "wagmi";
|
||||||
|
import {
|
||||||
|
ArrowLeftOnRectangleIcon,
|
||||||
|
ArrowTopRightOnSquareIcon,
|
||||||
|
ArrowsRightLeftIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
|
DocumentDuplicateIcon,
|
||||||
|
EyeIcon,
|
||||||
|
QrCodeIcon,
|
||||||
|
} from "@heroicons/react/24/outline";
|
||||||
|
import { BlockieAvatar } from "~~/components/scaffold-eth";
|
||||||
|
import { useCopyToClipboard, useOutsideClick } from "~~/hooks/scaffold-eth";
|
||||||
|
import { getTargetNetworks } from "~~/utils/scaffold-eth";
|
||||||
|
import { isENS } from "~~/utils/scaffold-eth/common";
|
||||||
|
|
||||||
|
const BURNER_WALLET_ID = "burnerWallet";
|
||||||
|
|
||||||
|
const allowedNetworks = getTargetNetworks();
|
||||||
|
|
||||||
|
type AddressInfoDropdownProps = {
|
||||||
|
address: Address;
|
||||||
|
blockExplorerAddressLink: string | undefined;
|
||||||
|
displayName: string;
|
||||||
|
ensAvatar?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddressInfoDropdown = ({
|
||||||
|
address,
|
||||||
|
ensAvatar,
|
||||||
|
displayName,
|
||||||
|
blockExplorerAddressLink,
|
||||||
|
}: AddressInfoDropdownProps) => {
|
||||||
|
const { disconnect } = useDisconnect();
|
||||||
|
const { connector } = useAccount();
|
||||||
|
const checkSumAddress = getAddress(address);
|
||||||
|
|
||||||
|
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
|
||||||
|
useCopyToClipboard();
|
||||||
|
const [selectingNetwork, setSelectingNetwork] = useState(false);
|
||||||
|
const dropdownRef = useRef<HTMLDetailsElement>(null);
|
||||||
|
|
||||||
|
const closeDropdown = () => {
|
||||||
|
setSelectingNetwork(false);
|
||||||
|
dropdownRef.current?.removeAttribute("open");
|
||||||
|
};
|
||||||
|
|
||||||
|
useOutsideClick(dropdownRef, closeDropdown);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<details ref={dropdownRef} className="dropdown dropdown-end leading-3">
|
||||||
|
<summary className="btn btn-secondary btn-sm pl-0 pr-2 shadow-md dropdown-toggle gap-0 h-auto!">
|
||||||
|
<BlockieAvatar address={checkSumAddress} size={30} ensImage={ensAvatar} />
|
||||||
|
<span className="ml-2 mr-1">
|
||||||
|
{isENS(displayName) ? displayName : checkSumAddress?.slice(0, 6) + "..." + checkSumAddress?.slice(-4)}
|
||||||
|
</span>
|
||||||
|
<ChevronDownIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||||
|
</summary>
|
||||||
|
<ul className="dropdown-content menu z-2 p-2 mt-2 shadow-center shadow-accent bg-base-200 rounded-box gap-1">
|
||||||
|
<NetworkOptions hidden={!selectingNetwork} />
|
||||||
|
<li className={selectingNetwork ? "hidden" : ""}>
|
||||||
|
<div
|
||||||
|
className="h-8 btn-sm rounded-xl! flex gap-3 py-3 cursor-pointer"
|
||||||
|
onClick={() => copyAddressToClipboard(checkSumAddress)}
|
||||||
|
>
|
||||||
|
{isAddressCopiedToClipboard ? (
|
||||||
|
<>
|
||||||
|
<CheckCircleIcon className="text-xl font-normal h-6 w-4 ml-2 sm:ml-0" aria-hidden="true" />
|
||||||
|
<span className="whitespace-nowrap">Copied!</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DocumentDuplicateIcon className="text-xl font-normal h-6 w-4 ml-2 sm:ml-0" aria-hidden="true" />
|
||||||
|
<span className="whitespace-nowrap">Copy address</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li className={selectingNetwork ? "hidden" : ""}>
|
||||||
|
<label htmlFor="qrcode-modal" className="h-8 btn-sm rounded-xl! flex gap-3 py-3">
|
||||||
|
<QrCodeIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||||
|
<span className="whitespace-nowrap">View QR Code</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<li className={selectingNetwork ? "hidden" : ""}>
|
||||||
|
<button className="h-8 btn-sm rounded-xl! flex gap-3 py-3" type="button">
|
||||||
|
<ArrowTopRightOnSquareIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||||
|
<a
|
||||||
|
target="_blank"
|
||||||
|
href={blockExplorerAddressLink}
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
>
|
||||||
|
View on Block Explorer
|
||||||
|
</a>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{allowedNetworks.length > 1 ? (
|
||||||
|
<li className={selectingNetwork ? "hidden" : ""}>
|
||||||
|
<button
|
||||||
|
className="h-8 btn-sm rounded-xl! flex gap-3 py-3"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectingNetwork(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowsRightLeftIcon className="h-6 w-4 ml-2 sm:ml-0" /> <span>Switch Network</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
{connector?.id === BURNER_WALLET_ID ? (
|
||||||
|
<li>
|
||||||
|
<label htmlFor="reveal-burner-pk-modal" className="h-8 btn-sm rounded-xl! flex gap-3 py-3 text-error">
|
||||||
|
<EyeIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||||
|
<span>Reveal Private Key</span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
<li className={selectingNetwork ? "hidden" : ""}>
|
||||||
|
<button
|
||||||
|
className="menu-item text-error h-8 btn-sm rounded-xl! flex gap-3 py-3"
|
||||||
|
type="button"
|
||||||
|
onClick={() => disconnect()}
|
||||||
|
>
|
||||||
|
<ArrowLeftOnRectangleIcon className="h-6 w-4 ml-2 sm:ml-0" /> <span>Disconnect</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Address } from "@scaffold-ui/components";
|
||||||
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
|
import { Address as AddressType } from "viem";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { useTargetNetwork } from "~~/hooks/scaffold-eth";
|
||||||
|
|
||||||
|
type AddressQRCodeModalProps = {
|
||||||
|
address: AddressType;
|
||||||
|
modalId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AddressQRCodeModal = ({ address, modalId }: AddressQRCodeModalProps) => {
|
||||||
|
const { targetNetwork } = useTargetNetwork();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<input type="checkbox" id={`${modalId}`} className="modal-toggle" />
|
||||||
|
<label htmlFor={`${modalId}`} className="modal cursor-pointer">
|
||||||
|
<label className="modal-box relative">
|
||||||
|
{/* dummy input to capture event onclick on modal box */}
|
||||||
|
<input className="h-0 w-0 absolute top-0 left-0" />
|
||||||
|
<label htmlFor={`${modalId}`} className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
|
||||||
|
✕
|
||||||
|
</label>
|
||||||
|
<div className="space-y-3 py-6">
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
<QRCodeSVG value={address} size={256} />
|
||||||
|
<Address
|
||||||
|
address={address}
|
||||||
|
format="long"
|
||||||
|
disableAddressLink
|
||||||
|
onlyEnsOrAddress
|
||||||
|
blockExplorerAddressLink={
|
||||||
|
targetNetwork.id === hardhat.id ? `/blockexplorer/address/${address}` : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useAccount, useSwitchChain } from "wagmi";
|
||||||
|
import { ArrowsRightLeftIcon } from "@heroicons/react/24/solid";
|
||||||
|
import { getNetworkColor } from "~~/hooks/scaffold-eth";
|
||||||
|
import { getTargetNetworks } from "~~/utils/scaffold-eth";
|
||||||
|
|
||||||
|
const allowedNetworks = getTargetNetworks();
|
||||||
|
|
||||||
|
type NetworkOptionsProps = {
|
||||||
|
hidden?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NetworkOptions = ({ hidden = false }: NetworkOptionsProps) => {
|
||||||
|
const { switchChain } = useSwitchChain();
|
||||||
|
const { chain } = useAccount();
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
const isDarkMode = resolvedTheme === "dark";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{allowedNetworks
|
||||||
|
.filter(allowedNetwork => allowedNetwork.id !== chain?.id)
|
||||||
|
.map(allowedNetwork => (
|
||||||
|
<li key={allowedNetwork.id} className={hidden ? "hidden" : ""}>
|
||||||
|
<button
|
||||||
|
className="menu-item btn-sm rounded-xl! flex gap-3 py-3 whitespace-nowrap"
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
switchChain?.({ chainId: allowedNetwork.id });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowsRightLeftIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||||
|
<span>
|
||||||
|
Switch to{" "}
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
color: getNetworkColor(allowedNetwork, isDarkMode),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{allowedNetwork.name}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { rainbowkitBurnerWallet } from "burner-connector";
|
||||||
|
import { ShieldExclamationIcon } from "@heroicons/react/24/outline";
|
||||||
|
import { useCopyToClipboard } from "~~/hooks/scaffold-eth";
|
||||||
|
import { getParsedError, notification } from "~~/utils/scaffold-eth";
|
||||||
|
|
||||||
|
const BURNER_WALLET_PK_KEY = "burnerWallet.pk";
|
||||||
|
|
||||||
|
export const RevealBurnerPKModal = () => {
|
||||||
|
const { copyToClipboard, isCopiedToClipboard } = useCopyToClipboard();
|
||||||
|
const modalCheckboxRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleCopyPK = async () => {
|
||||||
|
try {
|
||||||
|
const storage = rainbowkitBurnerWallet.useSessionStorage ? sessionStorage : localStorage;
|
||||||
|
const burnerPK = storage?.getItem(BURNER_WALLET_PK_KEY);
|
||||||
|
if (!burnerPK) throw new Error("Burner wallet private key not found");
|
||||||
|
await copyToClipboard(burnerPK);
|
||||||
|
notification.success("Burner wallet private key copied to clipboard");
|
||||||
|
} catch (e) {
|
||||||
|
const parsedError = getParsedError(e);
|
||||||
|
notification.error(parsedError);
|
||||||
|
if (modalCheckboxRef.current) modalCheckboxRef.current.checked = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<input type="checkbox" id="reveal-burner-pk-modal" className="modal-toggle" ref={modalCheckboxRef} />
|
||||||
|
<label htmlFor="reveal-burner-pk-modal" className="modal cursor-pointer">
|
||||||
|
<label className="modal-box relative">
|
||||||
|
{/* dummy input to capture event onclick on modal box */}
|
||||||
|
<input className="h-0 w-0 absolute top-0 left-0" />
|
||||||
|
<label htmlFor="reveal-burner-pk-modal" className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
|
||||||
|
✕
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-semibold m-0 p-0">Copy Burner Wallet Private Key</p>
|
||||||
|
<div role="alert" className="alert alert-warning mt-4">
|
||||||
|
<ShieldExclamationIcon className="h-6 w-6" />
|
||||||
|
<span className="font-semibold">
|
||||||
|
Burner wallets are intended for local development only and are not safe for storing real funds.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Your Private Key provides <strong>full access</strong> to your entire wallet and funds. This is
|
||||||
|
currently stored <strong>temporarily</strong> in your browser.
|
||||||
|
</p>
|
||||||
|
<button className="btn btn-outline btn-error" onClick={handleCopyPK} disabled={isCopiedToClipboard}>
|
||||||
|
Copy Private Key To Clipboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NetworkOptions } from "./NetworkOptions";
|
||||||
|
import { useDisconnect } from "wagmi";
|
||||||
|
import { ArrowLeftOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||||
|
|
||||||
|
export const WrongNetworkDropdown = () => {
|
||||||
|
const { disconnect } = useDisconnect();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dropdown dropdown-end mr-2">
|
||||||
|
<label tabIndex={0} className="btn btn-error btn-sm dropdown-toggle gap-1">
|
||||||
|
<span>Wrong network</span>
|
||||||
|
<ChevronDownIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||||
|
</label>
|
||||||
|
<ul
|
||||||
|
tabIndex={0}
|
||||||
|
className="dropdown-content menu p-2 mt-1 shadow-center shadow-accent bg-base-200 rounded-box gap-1"
|
||||||
|
>
|
||||||
|
<NetworkOptions />
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
className="menu-item text-error btn-sm rounded-xl! flex gap-3 py-3"
|
||||||
|
type="button"
|
||||||
|
onClick={() => disconnect()}
|
||||||
|
>
|
||||||
|
<ArrowLeftOnRectangleIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||||
|
<span>Disconnect</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// @refresh reset
|
||||||
|
import { AddressInfoDropdown } from "./AddressInfoDropdown";
|
||||||
|
import { AddressQRCodeModal } from "./AddressQRCodeModal";
|
||||||
|
import { RevealBurnerPKModal } from "./RevealBurnerPKModal";
|
||||||
|
import { WrongNetworkDropdown } from "./WrongNetworkDropdown";
|
||||||
|
import { ConnectButton } from "@rainbow-me/rainbowkit";
|
||||||
|
import { Balance } from "@scaffold-ui/components";
|
||||||
|
import { Address } from "viem";
|
||||||
|
import { useNetworkColor } from "~~/hooks/scaffold-eth";
|
||||||
|
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||||
|
import { getBlockExplorerAddressLink } from "~~/utils/scaffold-eth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Wagmi Connect Button (watch balance + custom design)
|
||||||
|
*/
|
||||||
|
export const RainbowKitCustomConnectButton = () => {
|
||||||
|
const networkColor = useNetworkColor();
|
||||||
|
const { targetNetwork } = useTargetNetwork();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConnectButton.Custom>
|
||||||
|
{({ account, chain, openConnectModal, mounted }) => {
|
||||||
|
const connected = mounted && account && chain;
|
||||||
|
const blockExplorerAddressLink = account
|
||||||
|
? getBlockExplorerAddressLink(targetNetwork, account.address)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{(() => {
|
||||||
|
if (!connected) {
|
||||||
|
return (
|
||||||
|
<button className="btn btn-primary btn-sm" onClick={openConnectModal} type="button">
|
||||||
|
Connect Wallet
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chain.unsupported || chain.id !== targetNetwork.id) {
|
||||||
|
return <WrongNetworkDropdown />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-col items-center mr-2">
|
||||||
|
<Balance
|
||||||
|
address={account.address as Address}
|
||||||
|
style={{
|
||||||
|
minHeight: "0",
|
||||||
|
height: "auto",
|
||||||
|
fontSize: "0.8em",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs" style={{ color: networkColor }}>
|
||||||
|
{chain.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<AddressInfoDropdown
|
||||||
|
address={account.address as Address}
|
||||||
|
displayName={account.displayName}
|
||||||
|
ensAvatar={account.ensAvatar}
|
||||||
|
blockExplorerAddressLink={blockExplorerAddressLink}
|
||||||
|
/>
|
||||||
|
<AddressQRCodeModal address={account.address as Address} modalId="qrcode-modal" />
|
||||||
|
<RevealBurnerPKModal />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ConnectButton.Custom>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
packages/nextjs/components/scaffold-eth/index.tsx
Normal file
4
packages/nextjs/components/scaffold-eth/index.tsx
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from "./BlockieAvatar";
|
||||||
|
export * from "./Faucet";
|
||||||
|
export * from "./FaucetButton";
|
||||||
|
export * from "./RainbowKitCustomConnectButton";
|
||||||
9
packages/nextjs/contracts/deployedContracts.ts
Normal file
9
packages/nextjs/contracts/deployedContracts.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* This file is autogenerated by Scaffold-ETH.
|
||||||
|
* You should not edit it manually or your changes might be overwritten.
|
||||||
|
*/
|
||||||
|
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
|
||||||
|
|
||||||
|
const deployedContracts = {} as const;
|
||||||
|
|
||||||
|
export default deployedContracts satisfies GenericContractsDeclaration;
|
||||||
16
packages/nextjs/contracts/externalContracts.ts
Normal file
16
packages/nextjs/contracts/externalContracts.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @example
|
||||||
|
* const externalContracts = {
|
||||||
|
* 1: {
|
||||||
|
* DAI: {
|
||||||
|
* address: "0x...",
|
||||||
|
* abi: [...],
|
||||||
|
* },
|
||||||
|
* },
|
||||||
|
* } as const;
|
||||||
|
*/
|
||||||
|
const externalContracts = {} as const;
|
||||||
|
|
||||||
|
export default externalContracts satisfies GenericContractsDeclaration;
|
||||||
32
packages/nextjs/eslint.config.mjs
Normal file
32
packages/nextjs/eslint.config.mjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { FlatCompat } from "@eslint/eslintrc";
|
||||||
|
import prettierPlugin from "eslint-plugin-prettier";
|
||||||
|
import { defineConfig } from "eslint/config";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
const compat = new FlatCompat({
|
||||||
|
baseDirectory: __dirname,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{
|
||||||
|
plugins: {
|
||||||
|
prettier: prettierPlugin,
|
||||||
|
},
|
||||||
|
extends: compat.extends("next/core-web-vitals", "next/typescript", "prettier"),
|
||||||
|
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/ban-ts-comment": "off",
|
||||||
|
|
||||||
|
"prettier/prettier": [
|
||||||
|
"warn",
|
||||||
|
{
|
||||||
|
endOfLine: "auto",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
14
packages/nextjs/hooks/scaffold-eth/index.ts
Normal file
14
packages/nextjs/hooks/scaffold-eth/index.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
export * from "./useContractLogs";
|
||||||
|
export * from "./useCopyToClipboard";
|
||||||
|
export * from "./useDeployedContractInfo";
|
||||||
|
export * from "./useFetchBlocks";
|
||||||
|
export * from "./useNetworkColor";
|
||||||
|
export * from "./useOutsideClick";
|
||||||
|
export * from "./useScaffoldContract";
|
||||||
|
export * from "./useScaffoldEventHistory";
|
||||||
|
export * from "./useScaffoldReadContract";
|
||||||
|
export * from "./useScaffoldWatchContractEvent";
|
||||||
|
export * from "./useScaffoldWriteContract";
|
||||||
|
export * from "./useTargetNetwork";
|
||||||
|
export * from "./useTransactor";
|
||||||
|
export * from "./useSelectedNetwork";
|
||||||
40
packages/nextjs/hooks/scaffold-eth/useContractLogs.ts
Normal file
40
packages/nextjs/hooks/scaffold-eth/useContractLogs.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTargetNetwork } from "./useTargetNetwork";
|
||||||
|
import { Address, Log } from "viem";
|
||||||
|
import { usePublicClient } from "wagmi";
|
||||||
|
|
||||||
|
export const useContractLogs = (address: Address) => {
|
||||||
|
const [logs, setLogs] = useState<Log[]>([]);
|
||||||
|
const { targetNetwork } = useTargetNetwork();
|
||||||
|
const client = usePublicClient({ chainId: targetNetwork.id });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchLogs = async () => {
|
||||||
|
if (!client) return console.error("Client not found");
|
||||||
|
try {
|
||||||
|
const existingLogs = await client.getLogs({
|
||||||
|
address: address,
|
||||||
|
fromBlock: 0n,
|
||||||
|
toBlock: "latest",
|
||||||
|
});
|
||||||
|
setLogs(existingLogs);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch logs:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchLogs();
|
||||||
|
|
||||||
|
return client?.watchBlockNumber({
|
||||||
|
onBlockNumber: async (_blockNumber, prevBlockNumber) => {
|
||||||
|
const newLogs = await client.getLogs({
|
||||||
|
address: address,
|
||||||
|
fromBlock: prevBlockNumber,
|
||||||
|
toBlock: "latest",
|
||||||
|
});
|
||||||
|
setLogs(prevLogs => [...prevLogs, ...newLogs]);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [address, client]);
|
||||||
|
|
||||||
|
return logs;
|
||||||
|
};
|
||||||
19
packages/nextjs/hooks/scaffold-eth/useCopyToClipboard.ts
Normal file
19
packages/nextjs/hooks/scaffold-eth/useCopyToClipboard.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export const useCopyToClipboard = () => {
|
||||||
|
const [isCopiedToClipboard, setIsCopiedToClipboard] = useState(false);
|
||||||
|
|
||||||
|
const copyToClipboard = async (text: string) => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
setIsCopiedToClipboard(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsCopiedToClipboard(false);
|
||||||
|
}, 800);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to copy text:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { copyToClipboard, isCopiedToClipboard };
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useIsMounted } from "usehooks-ts";
|
||||||
|
import { usePublicClient } from "wagmi";
|
||||||
|
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
|
||||||
|
import {
|
||||||
|
Contract,
|
||||||
|
ContractCodeStatus,
|
||||||
|
ContractName,
|
||||||
|
UseDeployedContractConfig,
|
||||||
|
contracts,
|
||||||
|
} from "~~/utils/scaffold-eth/contract";
|
||||||
|
|
||||||
|
type DeployedContractData<TContractName extends ContractName> = {
|
||||||
|
data: Contract<TContractName> | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the matching contract info for the provided contract name from the contracts present in deployedContracts.ts
|
||||||
|
* and externalContracts.ts corresponding to targetNetworks configured in scaffold.config.ts
|
||||||
|
*/
|
||||||
|
export function useDeployedContractInfo<TContractName extends ContractName>(
|
||||||
|
config: UseDeployedContractConfig<TContractName>,
|
||||||
|
): DeployedContractData<TContractName>;
|
||||||
|
/**
|
||||||
|
* @deprecated Use object parameter version instead: useDeployedContractInfo({ contractName: "YourContract" })
|
||||||
|
*/
|
||||||
|
export function useDeployedContractInfo<TContractName extends ContractName>(
|
||||||
|
contractName: TContractName,
|
||||||
|
): DeployedContractData<TContractName>;
|
||||||
|
|
||||||
|
export function useDeployedContractInfo<TContractName extends ContractName>(
|
||||||
|
configOrName: UseDeployedContractConfig<TContractName> | TContractName,
|
||||||
|
): DeployedContractData<TContractName> {
|
||||||
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
|
const finalConfig: UseDeployedContractConfig<TContractName> =
|
||||||
|
typeof configOrName === "string" ? { contractName: configOrName } : (configOrName as any);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof configOrName === "string") {
|
||||||
|
console.warn(
|
||||||
|
"Using `useDeployedContractInfo` with a string parameter is deprecated. Please use the object parameter version instead.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [configOrName]);
|
||||||
|
const { contractName, chainId } = finalConfig;
|
||||||
|
const selectedNetwork = useSelectedNetwork(chainId);
|
||||||
|
const deployedContract = contracts?.[selectedNetwork.id]?.[contractName as ContractName] as Contract<TContractName>;
|
||||||
|
const [status, setStatus] = useState<ContractCodeStatus>(ContractCodeStatus.LOADING);
|
||||||
|
const publicClient = usePublicClient({ chainId: selectedNetwork.id });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const checkContractDeployment = async () => {
|
||||||
|
try {
|
||||||
|
if (!isMounted() || !publicClient) return;
|
||||||
|
|
||||||
|
if (!deployedContract) {
|
||||||
|
setStatus(ContractCodeStatus.NOT_FOUND);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await publicClient.getBytecode({
|
||||||
|
address: deployedContract.address,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If contract code is `0x` => no contract deployed on that address
|
||||||
|
if (code === "0x") {
|
||||||
|
setStatus(ContractCodeStatus.NOT_FOUND);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(ContractCodeStatus.DEPLOYED);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
setStatus(ContractCodeStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
checkContractDeployment();
|
||||||
|
}, [isMounted, contractName, deployedContract, publicClient]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: status === ContractCodeStatus.DEPLOYED ? deployedContract : undefined,
|
||||||
|
isLoading: status === ContractCodeStatus.LOADING,
|
||||||
|
};
|
||||||
|
}
|
||||||
133
packages/nextjs/hooks/scaffold-eth/useFetchBlocks.ts
Normal file
133
packages/nextjs/hooks/scaffold-eth/useFetchBlocks.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Block,
|
||||||
|
Hash,
|
||||||
|
Transaction,
|
||||||
|
TransactionReceipt,
|
||||||
|
createTestClient,
|
||||||
|
publicActions,
|
||||||
|
walletActions,
|
||||||
|
webSocket,
|
||||||
|
} from "viem";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { decodeTransactionData } from "~~/utils/scaffold-eth";
|
||||||
|
|
||||||
|
const BLOCKS_PER_PAGE = 20;
|
||||||
|
|
||||||
|
export const testClient = createTestClient({
|
||||||
|
chain: hardhat,
|
||||||
|
mode: "hardhat",
|
||||||
|
transport: webSocket("ws://127.0.0.1:8545"),
|
||||||
|
})
|
||||||
|
.extend(publicActions)
|
||||||
|
.extend(walletActions);
|
||||||
|
|
||||||
|
export const useFetchBlocks = () => {
|
||||||
|
const [blocks, setBlocks] = useState<Block[]>([]);
|
||||||
|
const [transactionReceipts, setTransactionReceipts] = useState<{
|
||||||
|
[key: string]: TransactionReceipt;
|
||||||
|
}>({});
|
||||||
|
const [currentPage, setCurrentPage] = useState(0);
|
||||||
|
const [totalBlocks, setTotalBlocks] = useState(0n);
|
||||||
|
const [error, setError] = useState<Error | null>(null);
|
||||||
|
|
||||||
|
const fetchBlocks = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const blockNumber = await testClient.getBlockNumber();
|
||||||
|
setTotalBlocks(blockNumber);
|
||||||
|
|
||||||
|
const startingBlock = blockNumber - BigInt(currentPage * BLOCKS_PER_PAGE);
|
||||||
|
const blockNumbersToFetch = Array.from(
|
||||||
|
{ length: Number(BLOCKS_PER_PAGE < startingBlock + 1n ? BLOCKS_PER_PAGE : startingBlock + 1n) },
|
||||||
|
(_, i) => startingBlock - BigInt(i),
|
||||||
|
);
|
||||||
|
|
||||||
|
const blocksWithTransactions = blockNumbersToFetch.map(async blockNumber => {
|
||||||
|
try {
|
||||||
|
return testClient.getBlock({ blockNumber, includeTransactions: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error("An error occurred."));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const fetchedBlocks = await Promise.all(blocksWithTransactions);
|
||||||
|
|
||||||
|
fetchedBlocks.forEach(block => {
|
||||||
|
block.transactions.forEach(tx => decodeTransactionData(tx as Transaction));
|
||||||
|
});
|
||||||
|
|
||||||
|
const txReceipts = await Promise.all(
|
||||||
|
fetchedBlocks.flatMap(block =>
|
||||||
|
block.transactions.map(async tx => {
|
||||||
|
try {
|
||||||
|
const receipt = await testClient.getTransactionReceipt({ hash: (tx as Transaction).hash });
|
||||||
|
return { [(tx as Transaction).hash]: receipt };
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error("An error occurred."));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
setBlocks(fetchedBlocks);
|
||||||
|
setTransactionReceipts(prevReceipts => ({ ...prevReceipts, ...Object.assign({}, ...txReceipts) }));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error("An error occurred."));
|
||||||
|
}
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchBlocks();
|
||||||
|
}, [fetchBlocks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleNewBlock = async (newBlock: any) => {
|
||||||
|
try {
|
||||||
|
if (currentPage === 0) {
|
||||||
|
if (newBlock.transactions.length > 0) {
|
||||||
|
const transactionsDetails = await Promise.all(
|
||||||
|
newBlock.transactions.map((txHash: string) => testClient.getTransaction({ hash: txHash as Hash })),
|
||||||
|
);
|
||||||
|
newBlock.transactions = transactionsDetails;
|
||||||
|
}
|
||||||
|
|
||||||
|
newBlock.transactions.forEach((tx: Transaction) => decodeTransactionData(tx as Transaction));
|
||||||
|
|
||||||
|
const receipts = await Promise.all(
|
||||||
|
newBlock.transactions.map(async (tx: Transaction) => {
|
||||||
|
try {
|
||||||
|
const receipt = await testClient.getTransactionReceipt({ hash: (tx as Transaction).hash });
|
||||||
|
return { [(tx as Transaction).hash]: receipt };
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error("An error occurred fetching receipt."));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
setBlocks(prevBlocks => [newBlock, ...prevBlocks.slice(0, BLOCKS_PER_PAGE - 1)]);
|
||||||
|
setTransactionReceipts(prevReceipts => ({ ...prevReceipts, ...Object.assign({}, ...receipts) }));
|
||||||
|
}
|
||||||
|
if (newBlock.number) {
|
||||||
|
setTotalBlocks(newBlock.number);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err : new Error("An error occurred."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return testClient.watchBlocks({ onBlock: handleNewBlock, includeTransactions: true });
|
||||||
|
}, [currentPage]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
blocks,
|
||||||
|
transactionReceipts,
|
||||||
|
currentPage,
|
||||||
|
totalBlocks,
|
||||||
|
setCurrentPage,
|
||||||
|
error,
|
||||||
|
};
|
||||||
|
};
|
||||||
22
packages/nextjs/hooks/scaffold-eth/useNetworkColor.ts
Normal file
22
packages/nextjs/hooks/scaffold-eth/useNetworkColor.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
|
||||||
|
import { AllowedChainIds, ChainWithAttributes } from "~~/utils/scaffold-eth";
|
||||||
|
|
||||||
|
export const DEFAULT_NETWORK_COLOR: [string, string] = ["#666666", "#bbbbbb"];
|
||||||
|
|
||||||
|
export function getNetworkColor(network: ChainWithAttributes, isDarkMode: boolean) {
|
||||||
|
const colorConfig = network.color ?? DEFAULT_NETWORK_COLOR;
|
||||||
|
return Array.isArray(colorConfig) ? (isDarkMode ? colorConfig[1] : colorConfig[0]) : colorConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the color of the target network
|
||||||
|
*/
|
||||||
|
export const useNetworkColor = (chainId?: AllowedChainIds) => {
|
||||||
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
|
const chain = useSelectedNetwork(chainId);
|
||||||
|
const isDarkMode = resolvedTheme === "dark";
|
||||||
|
|
||||||
|
return getNetworkColor(chain, isDarkMode);
|
||||||
|
};
|
||||||
23
packages/nextjs/hooks/scaffold-eth/useOutsideClick.ts
Normal file
23
packages/nextjs/hooks/scaffold-eth/useOutsideClick.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import React, { useEffect } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles clicks outside of passed ref element
|
||||||
|
* @param ref - react ref of the element
|
||||||
|
* @param callback - callback function to call when clicked outside
|
||||||
|
*/
|
||||||
|
export const useOutsideClick = (ref: React.RefObject<HTMLElement | null>, callback: { (): void }) => {
|
||||||
|
useEffect(() => {
|
||||||
|
function handleOutsideClick(event: MouseEvent) {
|
||||||
|
if (!(event.target instanceof Element)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ref.current && !ref.current.contains(event.target)) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("click", handleOutsideClick);
|
||||||
|
return () => document.removeEventListener("click", handleOutsideClick);
|
||||||
|
}, [ref, callback]);
|
||||||
|
};
|
||||||
65
packages/nextjs/hooks/scaffold-eth/useScaffoldContract.ts
Normal file
65
packages/nextjs/hooks/scaffold-eth/useScaffoldContract.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { Account, Address, Chain, Client, Transport, getContract } from "viem";
|
||||||
|
import { usePublicClient } from "wagmi";
|
||||||
|
import { GetWalletClientReturnType } from "wagmi/actions";
|
||||||
|
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
|
||||||
|
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
|
||||||
|
import { AllowedChainIds } from "~~/utils/scaffold-eth";
|
||||||
|
import { Contract, ContractName } from "~~/utils/scaffold-eth/contract";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a viem instance of the contract present in deployedContracts.ts or externalContracts.ts corresponding to
|
||||||
|
* targetNetworks configured in scaffold.config.ts. Optional walletClient can be passed for doing write transactions.
|
||||||
|
* @param config - The config settings for the hook
|
||||||
|
* @param config.contractName - deployed contract name
|
||||||
|
* @param config.walletClient - optional walletClient from wagmi useWalletClient hook can be passed for doing write transactions
|
||||||
|
* @param config.chainId - optional chainId that is configured with the scaffold project to make use for multi-chain interactions.
|
||||||
|
*/
|
||||||
|
export const useScaffoldContract = <
|
||||||
|
TContractName extends ContractName,
|
||||||
|
TWalletClient extends Exclude<GetWalletClientReturnType, null> | undefined,
|
||||||
|
>({
|
||||||
|
contractName,
|
||||||
|
walletClient,
|
||||||
|
chainId,
|
||||||
|
}: {
|
||||||
|
contractName: TContractName;
|
||||||
|
walletClient?: TWalletClient | null;
|
||||||
|
chainId?: AllowedChainIds;
|
||||||
|
}) => {
|
||||||
|
const selectedNetwork = useSelectedNetwork(chainId);
|
||||||
|
const { data: deployedContractData, isLoading: deployedContractLoading } = useDeployedContractInfo({
|
||||||
|
contractName,
|
||||||
|
chainId: selectedNetwork?.id as AllowedChainIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const publicClient = usePublicClient({ chainId: selectedNetwork?.id });
|
||||||
|
|
||||||
|
let contract = undefined;
|
||||||
|
if (deployedContractData && publicClient) {
|
||||||
|
contract = getContract<
|
||||||
|
Transport,
|
||||||
|
Address,
|
||||||
|
Contract<TContractName>["abi"],
|
||||||
|
TWalletClient extends Exclude<GetWalletClientReturnType, null>
|
||||||
|
? {
|
||||||
|
public: Client<Transport, Chain>;
|
||||||
|
wallet: TWalletClient;
|
||||||
|
}
|
||||||
|
: { public: Client<Transport, Chain> },
|
||||||
|
Chain,
|
||||||
|
Account
|
||||||
|
>({
|
||||||
|
address: deployedContractData.address,
|
||||||
|
abi: deployedContractData.abi as Contract<TContractName>["abi"],
|
||||||
|
client: {
|
||||||
|
public: publicClient,
|
||||||
|
wallet: walletClient ? walletClient : undefined,
|
||||||
|
} as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: contract,
|
||||||
|
isLoading: deployedContractLoading,
|
||||||
|
};
|
||||||
|
};
|
||||||
292
packages/nextjs/hooks/scaffold-eth/useScaffoldEventHistory.ts
Normal file
292
packages/nextjs/hooks/scaffold-eth/useScaffoldEventHistory.ts
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
||||||
|
import { Abi, AbiEvent, ExtractAbiEventNames } from "abitype";
|
||||||
|
import { BlockNumber, GetLogsParameters } from "viem";
|
||||||
|
import { hardhat } from "viem/chains";
|
||||||
|
import { Config, UsePublicClientReturnType, useBlockNumber, usePublicClient } from "wagmi";
|
||||||
|
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
|
||||||
|
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
|
||||||
|
import { AllowedChainIds } from "~~/utils/scaffold-eth";
|
||||||
|
import { replacer } from "~~/utils/scaffold-eth/common";
|
||||||
|
import {
|
||||||
|
ContractAbi,
|
||||||
|
ContractName,
|
||||||
|
UseScaffoldEventHistoryConfig,
|
||||||
|
UseScaffoldEventHistoryData,
|
||||||
|
} from "~~/utils/scaffold-eth/contract";
|
||||||
|
|
||||||
|
const getEvents = async (
|
||||||
|
getLogsParams: GetLogsParameters<AbiEvent | undefined, AbiEvent[] | undefined, boolean, BlockNumber, BlockNumber>,
|
||||||
|
publicClient?: UsePublicClientReturnType<Config, number>,
|
||||||
|
Options?: {
|
||||||
|
blockData?: boolean;
|
||||||
|
transactionData?: boolean;
|
||||||
|
receiptData?: boolean;
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const logs = await publicClient?.getLogs({
|
||||||
|
address: getLogsParams.address,
|
||||||
|
fromBlock: getLogsParams.fromBlock,
|
||||||
|
toBlock: getLogsParams.toBlock,
|
||||||
|
args: getLogsParams.args,
|
||||||
|
event: getLogsParams.event,
|
||||||
|
});
|
||||||
|
if (!logs) return undefined;
|
||||||
|
|
||||||
|
const finalEvents = await Promise.all(
|
||||||
|
logs.map(async log => {
|
||||||
|
return {
|
||||||
|
...log,
|
||||||
|
blockData:
|
||||||
|
Options?.blockData && log.blockHash ? await publicClient?.getBlock({ blockHash: log.blockHash }) : null,
|
||||||
|
transactionData:
|
||||||
|
Options?.transactionData && log.transactionHash
|
||||||
|
? await publicClient?.getTransaction({ hash: log.transactionHash })
|
||||||
|
: null,
|
||||||
|
receiptData:
|
||||||
|
Options?.receiptData && log.transactionHash
|
||||||
|
? await publicClient?.getTransactionReceipt({ hash: log.transactionHash })
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return finalEvents;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated **Recommended only for local (hardhat/anvil) chains and development.**
|
||||||
|
* It uses getLogs which can overload RPC endpoints (especially on L2s with short block times).
|
||||||
|
* For production, use an indexer such as ponder.sh or similar to query contract events efficiently.
|
||||||
|
*
|
||||||
|
* Reads events from a deployed contract.
|
||||||
|
* @param config - The config settings
|
||||||
|
* @param config.contractName - deployed contract name
|
||||||
|
* @param config.eventName - name of the event to listen for
|
||||||
|
* @param config.fromBlock - optional block number to start reading events from (defaults to `deployedOnBlock` in deployedContracts.ts if set for contract, otherwise defaults to 0)
|
||||||
|
* @param config.toBlock - optional block number to stop reading events at (if not provided, reads until current block)
|
||||||
|
* @param config.chainId - optional chainId that is configured with the scaffold project to make use for multi-chain interactions.
|
||||||
|
* @param config.filters - filters to be applied to the event (parameterName: value)
|
||||||
|
* @param config.blockData - if set to true it will return the block data for each event (default: false)
|
||||||
|
* @param config.transactionData - if set to true it will return the transaction data for each event (default: false)
|
||||||
|
* @param config.receiptData - if set to true it will return the receipt data for each event (default: false)
|
||||||
|
* @param config.watch - if set to true, the events will be updated every pollingInterval milliseconds set at scaffoldConfig (default: false)
|
||||||
|
* @param config.enabled - set this to false to disable the hook from running (default: true)
|
||||||
|
* @param config.blocksBatchSize - optional batch size for fetching events. If specified, each batch will contain at most this many blocks (default: 500)
|
||||||
|
*/
|
||||||
|
export const useScaffoldEventHistory = <
|
||||||
|
TContractName extends ContractName,
|
||||||
|
TEventName extends ExtractAbiEventNames<ContractAbi<TContractName>>,
|
||||||
|
TBlockData extends boolean = false,
|
||||||
|
TTransactionData extends boolean = false,
|
||||||
|
TReceiptData extends boolean = false,
|
||||||
|
>({
|
||||||
|
contractName,
|
||||||
|
eventName,
|
||||||
|
fromBlock,
|
||||||
|
toBlock,
|
||||||
|
chainId,
|
||||||
|
filters,
|
||||||
|
blockData,
|
||||||
|
transactionData,
|
||||||
|
receiptData,
|
||||||
|
watch,
|
||||||
|
enabled = true,
|
||||||
|
blocksBatchSize = 500,
|
||||||
|
}: UseScaffoldEventHistoryConfig<TContractName, TEventName, TBlockData, TTransactionData, TReceiptData>) => {
|
||||||
|
const selectedNetwork = useSelectedNetwork(chainId);
|
||||||
|
|
||||||
|
// Runtime warning for non-local chains
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedNetwork.id !== hardhat.id) {
|
||||||
|
console.log(
|
||||||
|
"⚠️ useScaffoldEventHistory is not optimized for production use. It can overload RPC endpoints (especially on L2s)",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [selectedNetwork.id]);
|
||||||
|
|
||||||
|
const publicClient = usePublicClient({
|
||||||
|
chainId: selectedNetwork.id,
|
||||||
|
});
|
||||||
|
const [liveEvents, setLiveEvents] = useState<any[]>([]);
|
||||||
|
const [lastFetchedBlock, setLastFetchedBlock] = useState<bigint | null>(null);
|
||||||
|
const [isPollingActive, setIsPollingActive] = useState(false);
|
||||||
|
|
||||||
|
const { data: blockNumber } = useBlockNumber({ watch: watch, chainId: selectedNetwork.id });
|
||||||
|
|
||||||
|
const { data: deployedContractData } = useDeployedContractInfo({
|
||||||
|
contractName,
|
||||||
|
chainId: selectedNetwork.id as AllowedChainIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const event =
|
||||||
|
deployedContractData &&
|
||||||
|
((deployedContractData.abi as Abi).find(part => part.type === "event" && part.name === eventName) as AbiEvent);
|
||||||
|
|
||||||
|
const isContractAddressAndClientReady = Boolean(deployedContractData?.address) && Boolean(publicClient);
|
||||||
|
|
||||||
|
const fromBlockValue =
|
||||||
|
fromBlock !== undefined
|
||||||
|
? fromBlock
|
||||||
|
: BigInt(
|
||||||
|
deployedContractData && "deployedOnBlock" in deployedContractData
|
||||||
|
? deployedContractData.deployedOnBlock || 0
|
||||||
|
: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = useInfiniteQuery({
|
||||||
|
queryKey: [
|
||||||
|
"eventHistory",
|
||||||
|
{
|
||||||
|
contractName,
|
||||||
|
address: deployedContractData?.address,
|
||||||
|
eventName,
|
||||||
|
fromBlock: fromBlockValue?.toString(),
|
||||||
|
toBlock: toBlock?.toString(),
|
||||||
|
chainId: selectedNetwork.id,
|
||||||
|
filters: JSON.stringify(filters, replacer),
|
||||||
|
blocksBatchSize: blocksBatchSize.toString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
if (!isContractAddressAndClientReady) return undefined;
|
||||||
|
|
||||||
|
// Calculate the toBlock for this batch
|
||||||
|
let batchToBlock = toBlock;
|
||||||
|
const batchEndBlock = pageParam + BigInt(blocksBatchSize) - 1n;
|
||||||
|
const maxBlock = toBlock || (blockNumber ? BigInt(blockNumber) : undefined);
|
||||||
|
if (maxBlock) {
|
||||||
|
batchToBlock = batchEndBlock < maxBlock ? batchEndBlock : maxBlock;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await getEvents(
|
||||||
|
{
|
||||||
|
address: deployedContractData?.address,
|
||||||
|
event,
|
||||||
|
fromBlock: pageParam,
|
||||||
|
toBlock: batchToBlock,
|
||||||
|
args: filters,
|
||||||
|
},
|
||||||
|
publicClient,
|
||||||
|
{ blockData, transactionData, receiptData },
|
||||||
|
);
|
||||||
|
|
||||||
|
setLastFetchedBlock(batchToBlock || blockNumber || 0n);
|
||||||
|
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
enabled: enabled && isContractAddressAndClientReady && !isPollingActive, // Disable when polling starts
|
||||||
|
initialPageParam: fromBlockValue,
|
||||||
|
getNextPageParam: (lastPage, allPages, lastPageParam) => {
|
||||||
|
if (!blockNumber || fromBlockValue >= blockNumber) return undefined;
|
||||||
|
|
||||||
|
const nextBlock = lastPageParam + BigInt(blocksBatchSize);
|
||||||
|
|
||||||
|
// Don't go beyond the specified toBlock or current block
|
||||||
|
const maxBlock = toBlock && toBlock < blockNumber ? toBlock : blockNumber;
|
||||||
|
|
||||||
|
if (nextBlock > maxBlock) return undefined;
|
||||||
|
|
||||||
|
return nextBlock;
|
||||||
|
},
|
||||||
|
select: data => {
|
||||||
|
const events = data.pages.flat() as unknown as UseScaffoldEventHistoryData<
|
||||||
|
TContractName,
|
||||||
|
TEventName,
|
||||||
|
TBlockData,
|
||||||
|
TTransactionData,
|
||||||
|
TReceiptData
|
||||||
|
>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
pages: events?.reverse(),
|
||||||
|
pageParams: data.pageParams,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check if we're caught up and should start polling
|
||||||
|
const shouldStartPolling = () => {
|
||||||
|
if (!watch || !blockNumber || isPollingActive) return false;
|
||||||
|
|
||||||
|
return !query.hasNextPage && query.status === "success";
|
||||||
|
};
|
||||||
|
|
||||||
|
// Poll for new events when watch mode is enabled
|
||||||
|
useQuery({
|
||||||
|
queryKey: ["liveEvents", contractName, eventName, blockNumber?.toString(), lastFetchedBlock?.toString()],
|
||||||
|
enabled: Boolean(
|
||||||
|
watch && enabled && isContractAddressAndClientReady && blockNumber && (shouldStartPolling() || isPollingActive),
|
||||||
|
),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!isContractAddressAndClientReady || !blockNumber) return null;
|
||||||
|
|
||||||
|
if (!isPollingActive && shouldStartPolling()) {
|
||||||
|
setIsPollingActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxBlock = toBlock && toBlock < blockNumber ? toBlock : blockNumber;
|
||||||
|
const startBlock = lastFetchedBlock || maxBlock;
|
||||||
|
|
||||||
|
// Only fetch if there are new blocks to check
|
||||||
|
if (startBlock >= maxBlock) return null;
|
||||||
|
|
||||||
|
const newEvents = await getEvents(
|
||||||
|
{
|
||||||
|
address: deployedContractData?.address,
|
||||||
|
event,
|
||||||
|
fromBlock: startBlock + 1n,
|
||||||
|
toBlock: maxBlock,
|
||||||
|
args: filters,
|
||||||
|
},
|
||||||
|
publicClient,
|
||||||
|
{ blockData, transactionData, receiptData },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (newEvents && newEvents.length > 0) {
|
||||||
|
setLiveEvents(prev => [...newEvents, ...prev]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastFetchedBlock(maxBlock);
|
||||||
|
return newEvents;
|
||||||
|
},
|
||||||
|
refetchInterval: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Manual trigger to fetch next page when previous page completes (only when not polling)
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isPollingActive &&
|
||||||
|
query.status === "success" &&
|
||||||
|
query.hasNextPage &&
|
||||||
|
!query.isFetchingNextPage &&
|
||||||
|
!query.error
|
||||||
|
) {
|
||||||
|
query.fetchNextPage();
|
||||||
|
}
|
||||||
|
}, [query, isPollingActive]);
|
||||||
|
|
||||||
|
// Combine historical data from infinite query with live events from watch hook
|
||||||
|
const historicalEvents = query.data?.pages || [];
|
||||||
|
const allEvents = [...liveEvents, ...historicalEvents] as typeof historicalEvents;
|
||||||
|
|
||||||
|
// remove duplicates
|
||||||
|
const seenEvents = new Set<string>();
|
||||||
|
const combinedEvents = allEvents.filter(event => {
|
||||||
|
const eventKey = `${event?.transactionHash}-${event?.logIndex}-${event?.blockHash}`;
|
||||||
|
if (seenEvents.has(eventKey)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
seenEvents.add(eventKey);
|
||||||
|
return true;
|
||||||
|
}) as typeof historicalEvents;
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: combinedEvents,
|
||||||
|
status: query.status,
|
||||||
|
error: query.error,
|
||||||
|
isLoading: query.isLoading,
|
||||||
|
isFetchingNewEvent: query.isFetchingNextPage,
|
||||||
|
refetch: query.refetch,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { QueryObserverResult, RefetchOptions, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { ExtractAbiFunctionNames } from "abitype";
|
||||||
|
import { ReadContractErrorType } from "viem";
|
||||||
|
import { useBlockNumber, useReadContract } from "wagmi";
|
||||||
|
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
|
||||||
|
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
|
||||||
|
import { AllowedChainIds } from "~~/utils/scaffold-eth";
|
||||||
|
import {
|
||||||
|
AbiFunctionReturnType,
|
||||||
|
ContractAbi,
|
||||||
|
ContractName,
|
||||||
|
UseScaffoldReadConfig,
|
||||||
|
} from "~~/utils/scaffold-eth/contract";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around wagmi's useContractRead hook which automatically loads (by name) the contract ABI and address from
|
||||||
|
* the contracts present in deployedContracts.ts & externalContracts.ts corresponding to targetNetworks configured in scaffold.config.ts
|
||||||
|
* @param config - The config settings, including extra wagmi configuration
|
||||||
|
* @param config.contractName - deployed contract name
|
||||||
|
* @param config.functionName - name of the function to be called
|
||||||
|
* @param config.args - args to be passed to the function call
|
||||||
|
* @param config.chainId - optional chainId that is configured with the scaffold project to make use for multi-chain interactions.
|
||||||
|
*/
|
||||||
|
export const useScaffoldReadContract = <
|
||||||
|
TContractName extends ContractName,
|
||||||
|
TFunctionName extends ExtractAbiFunctionNames<ContractAbi<TContractName>, "pure" | "view">,
|
||||||
|
>({
|
||||||
|
contractName,
|
||||||
|
functionName,
|
||||||
|
args,
|
||||||
|
chainId,
|
||||||
|
...readConfig
|
||||||
|
}: UseScaffoldReadConfig<TContractName, TFunctionName>) => {
|
||||||
|
const selectedNetwork = useSelectedNetwork(chainId);
|
||||||
|
const { data: deployedContract } = useDeployedContractInfo({
|
||||||
|
contractName,
|
||||||
|
chainId: selectedNetwork.id as AllowedChainIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { query: queryOptions, watch, ...readContractConfig } = readConfig;
|
||||||
|
// set watch to true by default
|
||||||
|
const defaultWatch = watch ?? true;
|
||||||
|
|
||||||
|
const readContractHookRes = useReadContract({
|
||||||
|
chainId: selectedNetwork.id,
|
||||||
|
functionName,
|
||||||
|
address: deployedContract?.address,
|
||||||
|
abi: deployedContract?.abi,
|
||||||
|
args,
|
||||||
|
...(readContractConfig as any),
|
||||||
|
query: {
|
||||||
|
enabled: !Array.isArray(args) || !args.some(arg => arg === undefined),
|
||||||
|
...queryOptions,
|
||||||
|
},
|
||||||
|
}) as Omit<ReturnType<typeof useReadContract>, "data" | "refetch"> & {
|
||||||
|
data: AbiFunctionReturnType<ContractAbi, TFunctionName> | undefined;
|
||||||
|
refetch: (
|
||||||
|
options?: RefetchOptions | undefined,
|
||||||
|
) => Promise<QueryObserverResult<AbiFunctionReturnType<ContractAbi, TFunctionName>, ReadContractErrorType>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: blockNumber } = useBlockNumber({
|
||||||
|
watch: defaultWatch,
|
||||||
|
chainId: selectedNetwork.id,
|
||||||
|
query: {
|
||||||
|
enabled: defaultWatch,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (defaultWatch) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: readContractHookRes.queryKey });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [blockNumber]);
|
||||||
|
|
||||||
|
return readContractHookRes;
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Abi, ExtractAbiEventNames } from "abitype";
|
||||||
|
import { Log } from "viem";
|
||||||
|
import { useWatchContractEvent } from "wagmi";
|
||||||
|
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
|
||||||
|
import { useDeployedContractInfo } from "~~/hooks/scaffold-eth";
|
||||||
|
import { AllowedChainIds } from "~~/utils/scaffold-eth";
|
||||||
|
import { ContractAbi, ContractName, UseScaffoldEventConfig } from "~~/utils/scaffold-eth/contract";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around wagmi's useEventSubscriber hook which automatically loads (by name) the contract ABI and
|
||||||
|
* address from the contracts present in deployedContracts.ts & externalContracts.ts
|
||||||
|
* @param config - The config settings
|
||||||
|
* @param config.contractName - deployed contract name
|
||||||
|
* @param config.eventName - name of the event to listen for
|
||||||
|
* @param config.chainId - optional chainId that is configured with the scaffold project to make use for multi-chain interactions.
|
||||||
|
* @param config.onLogs - the callback that receives events.
|
||||||
|
*/
|
||||||
|
export const useScaffoldWatchContractEvent = <
|
||||||
|
TContractName extends ContractName,
|
||||||
|
TEventName extends ExtractAbiEventNames<ContractAbi<TContractName>>,
|
||||||
|
>({
|
||||||
|
contractName,
|
||||||
|
eventName,
|
||||||
|
chainId,
|
||||||
|
onLogs,
|
||||||
|
}: UseScaffoldEventConfig<TContractName, TEventName>) => {
|
||||||
|
const selectedNetwork = useSelectedNetwork(chainId);
|
||||||
|
const { data: deployedContractData } = useDeployedContractInfo({
|
||||||
|
contractName,
|
||||||
|
chainId: selectedNetwork.id as AllowedChainIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
return useWatchContractEvent({
|
||||||
|
address: deployedContractData?.address,
|
||||||
|
abi: deployedContractData?.abi as Abi,
|
||||||
|
chainId: selectedNetwork.id,
|
||||||
|
onLogs: (logs: Log[]) => onLogs(logs as Parameters<typeof onLogs>[0]),
|
||||||
|
eventName,
|
||||||
|
});
|
||||||
|
};
|
||||||
194
packages/nextjs/hooks/scaffold-eth/useScaffoldWriteContract.ts
Normal file
194
packages/nextjs/hooks/scaffold-eth/useScaffoldWriteContract.ts
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { MutateOptions } from "@tanstack/react-query";
|
||||||
|
import { Abi, ExtractAbiFunctionNames } from "abitype";
|
||||||
|
import { Config, UseWriteContractParameters, useAccount, useConfig, useWriteContract } from "wagmi";
|
||||||
|
import { WriteContractErrorType, WriteContractReturnType } from "wagmi/actions";
|
||||||
|
import { WriteContractVariables } from "wagmi/query";
|
||||||
|
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
|
||||||
|
import { useDeployedContractInfo, useTransactor } from "~~/hooks/scaffold-eth";
|
||||||
|
import { AllowedChainIds, notification } from "~~/utils/scaffold-eth";
|
||||||
|
import {
|
||||||
|
ContractAbi,
|
||||||
|
ContractName,
|
||||||
|
ScaffoldWriteContractOptions,
|
||||||
|
ScaffoldWriteContractVariables,
|
||||||
|
UseScaffoldWriteConfig,
|
||||||
|
simulateContractWriteAndNotifyError,
|
||||||
|
} from "~~/utils/scaffold-eth/contract";
|
||||||
|
|
||||||
|
type ScaffoldWriteContractReturnType<TContractName extends ContractName> = Omit<
|
||||||
|
ReturnType<typeof useWriteContract>,
|
||||||
|
"writeContract" | "writeContractAsync"
|
||||||
|
> & {
|
||||||
|
isMining: boolean;
|
||||||
|
writeContractAsync: <
|
||||||
|
TFunctionName extends ExtractAbiFunctionNames<ContractAbi<TContractName>, "nonpayable" | "payable">,
|
||||||
|
>(
|
||||||
|
variables: ScaffoldWriteContractVariables<TContractName, TFunctionName>,
|
||||||
|
options?: ScaffoldWriteContractOptions,
|
||||||
|
) => Promise<WriteContractReturnType | undefined>;
|
||||||
|
writeContract: <TFunctionName extends ExtractAbiFunctionNames<ContractAbi<TContractName>, "nonpayable" | "payable">>(
|
||||||
|
variables: ScaffoldWriteContractVariables<TContractName, TFunctionName>,
|
||||||
|
options?: Omit<ScaffoldWriteContractOptions, "onBlockConfirmation" | "blockConfirmations">,
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useScaffoldWriteContract<TContractName extends ContractName>(
|
||||||
|
config: UseScaffoldWriteConfig<TContractName>,
|
||||||
|
): ScaffoldWriteContractReturnType<TContractName>;
|
||||||
|
/**
|
||||||
|
* @deprecated Use object parameter version instead: useScaffoldWriteContract({ contractName: "YourContract" })
|
||||||
|
*/
|
||||||
|
export function useScaffoldWriteContract<TContractName extends ContractName>(
|
||||||
|
contractName: TContractName,
|
||||||
|
writeContractParams?: UseWriteContractParameters,
|
||||||
|
): ScaffoldWriteContractReturnType<TContractName>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around wagmi's useWriteContract hook which automatically loads (by name) the contract ABI and address from
|
||||||
|
* the contracts present in deployedContracts.ts & externalContracts.ts corresponding to targetNetworks configured in scaffold.config.ts
|
||||||
|
* @param contractName - name of the contract to be written to
|
||||||
|
* @param config.chainId - optional chainId that is configured with the scaffold project to make use for multi-chain interactions.
|
||||||
|
* @param writeContractParams - wagmi's useWriteContract parameters
|
||||||
|
*/
|
||||||
|
export function useScaffoldWriteContract<TContractName extends ContractName>(
|
||||||
|
configOrName: UseScaffoldWriteConfig<TContractName> | TContractName,
|
||||||
|
writeContractParams?: UseWriteContractParameters,
|
||||||
|
): ScaffoldWriteContractReturnType<TContractName> {
|
||||||
|
const finalConfig =
|
||||||
|
typeof configOrName === "string"
|
||||||
|
? { contractName: configOrName, writeContractParams, chainId: undefined }
|
||||||
|
: (configOrName as UseScaffoldWriteConfig<TContractName>);
|
||||||
|
const { contractName, chainId, writeContractParams: finalWriteContractParams } = finalConfig;
|
||||||
|
|
||||||
|
const wagmiConfig = useConfig();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof configOrName === "string") {
|
||||||
|
console.warn(
|
||||||
|
"Using `useScaffoldWriteContract` with a string parameter is deprecated. Please use the object parameter version instead.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [configOrName]);
|
||||||
|
|
||||||
|
const { chain: accountChain } = useAccount();
|
||||||
|
const writeTx = useTransactor();
|
||||||
|
const [isMining, setIsMining] = useState(false);
|
||||||
|
|
||||||
|
const wagmiContractWrite = useWriteContract(finalWriteContractParams);
|
||||||
|
|
||||||
|
const selectedNetwork = useSelectedNetwork(chainId);
|
||||||
|
|
||||||
|
const { data: deployedContractData } = useDeployedContractInfo({
|
||||||
|
contractName,
|
||||||
|
chainId: selectedNetwork.id as AllowedChainIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendContractWriteAsyncTx = async <
|
||||||
|
TFunctionName extends ExtractAbiFunctionNames<ContractAbi<TContractName>, "nonpayable" | "payable">,
|
||||||
|
>(
|
||||||
|
variables: ScaffoldWriteContractVariables<TContractName, TFunctionName>,
|
||||||
|
options?: ScaffoldWriteContractOptions,
|
||||||
|
) => {
|
||||||
|
if (!deployedContractData) {
|
||||||
|
notification.error("Target Contract is not deployed, did you forget to run `yarn deploy`?");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accountChain?.id) {
|
||||||
|
notification.error("Please connect your wallet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountChain?.id !== selectedNetwork.id) {
|
||||||
|
notification.error(`Wallet is connected to the wrong network. Please switch to ${selectedNetwork.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsMining(true);
|
||||||
|
const { blockConfirmations, onBlockConfirmation, ...mutateOptions } = options || {};
|
||||||
|
|
||||||
|
const writeContractObject = {
|
||||||
|
abi: deployedContractData.abi as Abi,
|
||||||
|
address: deployedContractData.address,
|
||||||
|
...variables,
|
||||||
|
} as WriteContractVariables<Abi, string, any[], Config, number>;
|
||||||
|
|
||||||
|
if (!finalConfig?.disableSimulate) {
|
||||||
|
await simulateContractWriteAndNotifyError({
|
||||||
|
wagmiConfig,
|
||||||
|
writeContractParams: writeContractObject,
|
||||||
|
chainId: selectedNetwork.id as AllowedChainIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const makeWriteWithParams = () =>
|
||||||
|
wagmiContractWrite.writeContractAsync(
|
||||||
|
writeContractObject,
|
||||||
|
mutateOptions as
|
||||||
|
| MutateOptions<
|
||||||
|
WriteContractReturnType,
|
||||||
|
WriteContractErrorType,
|
||||||
|
WriteContractVariables<Abi, string, any[], Config, number>,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
| undefined,
|
||||||
|
);
|
||||||
|
const writeTxResult = await writeTx(makeWriteWithParams, { blockConfirmations, onBlockConfirmation });
|
||||||
|
|
||||||
|
return writeTxResult;
|
||||||
|
} catch (e: any) {
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
|
setIsMining(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendContractWriteTx = <
|
||||||
|
TContractName extends ContractName,
|
||||||
|
TFunctionName extends ExtractAbiFunctionNames<ContractAbi<TContractName>, "nonpayable" | "payable">,
|
||||||
|
>(
|
||||||
|
variables: ScaffoldWriteContractVariables<TContractName, TFunctionName>,
|
||||||
|
options?: Omit<ScaffoldWriteContractOptions, "onBlockConfirmation" | "blockConfirmations">,
|
||||||
|
) => {
|
||||||
|
if (!deployedContractData) {
|
||||||
|
notification.error("Target Contract is not deployed, did you forget to run `yarn deploy`?");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!accountChain?.id) {
|
||||||
|
notification.error("Please connect your wallet");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accountChain?.id !== selectedNetwork.id) {
|
||||||
|
notification.error(`Wallet is connected to the wrong network. Please switch to ${selectedNetwork.name}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wagmiContractWrite.writeContract(
|
||||||
|
{
|
||||||
|
abi: deployedContractData.abi as Abi,
|
||||||
|
address: deployedContractData.address,
|
||||||
|
...variables,
|
||||||
|
} as WriteContractVariables<Abi, string, any[], Config, number>,
|
||||||
|
options as
|
||||||
|
| MutateOptions<
|
||||||
|
WriteContractReturnType,
|
||||||
|
WriteContractErrorType,
|
||||||
|
WriteContractVariables<Abi, string, any[], Config, number>,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
| undefined,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...wagmiContractWrite,
|
||||||
|
isMining,
|
||||||
|
// Overwrite wagmi's writeContactAsync
|
||||||
|
writeContractAsync: sendContractWriteAsyncTx,
|
||||||
|
// Overwrite wagmi's writeContract
|
||||||
|
writeContract: sendContractWriteTx,
|
||||||
|
};
|
||||||
|
}
|
||||||
19
packages/nextjs/hooks/scaffold-eth/useSelectedNetwork.ts
Normal file
19
packages/nextjs/hooks/scaffold-eth/useSelectedNetwork.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import scaffoldConfig from "~~/scaffold.config";
|
||||||
|
import { useGlobalState } from "~~/services/store/store";
|
||||||
|
import { AllowedChainIds } from "~~/utils/scaffold-eth";
|
||||||
|
import { ChainWithAttributes, NETWORKS_EXTRA_DATA } from "~~/utils/scaffold-eth/networks";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Given a chainId, retrives the network object from `scaffold.config`,
|
||||||
|
* if not found default to network set by `useTargetNetwork` hook
|
||||||
|
*/
|
||||||
|
export function useSelectedNetwork(chainId?: AllowedChainIds): ChainWithAttributes {
|
||||||
|
const globalTargetNetwork = useGlobalState(({ targetNetwork }) => targetNetwork);
|
||||||
|
const targetNetwork = scaffoldConfig.targetNetworks.find(targetNetwork => targetNetwork.id === chainId);
|
||||||
|
|
||||||
|
if (targetNetwork) {
|
||||||
|
return { ...targetNetwork, ...NETWORKS_EXTRA_DATA[targetNetwork.id] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return globalTargetNetwork;
|
||||||
|
}
|
||||||
24
packages/nextjs/hooks/scaffold-eth/useTargetNetwork.ts
Normal file
24
packages/nextjs/hooks/scaffold-eth/useTargetNetwork.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useAccount } from "wagmi";
|
||||||
|
import scaffoldConfig from "~~/scaffold.config";
|
||||||
|
import { useGlobalState } from "~~/services/store/store";
|
||||||
|
import { ChainWithAttributes } from "~~/utils/scaffold-eth";
|
||||||
|
import { NETWORKS_EXTRA_DATA } from "~~/utils/scaffold-eth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the connected wallet's network from scaffold.config or defaults to the 0th network in the list if the wallet is not connected.
|
||||||
|
*/
|
||||||
|
export function useTargetNetwork(): { targetNetwork: ChainWithAttributes } {
|
||||||
|
const { chain } = useAccount();
|
||||||
|
const targetNetwork = useGlobalState(({ targetNetwork }) => targetNetwork);
|
||||||
|
const setTargetNetwork = useGlobalState(({ setTargetNetwork }) => setTargetNetwork);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newSelectedNetwork = scaffoldConfig.targetNetworks.find(targetNetwork => targetNetwork.id === chain?.id);
|
||||||
|
if (newSelectedNetwork && newSelectedNetwork.id !== targetNetwork.id) {
|
||||||
|
setTargetNetwork({ ...newSelectedNetwork, ...NETWORKS_EXTRA_DATA[newSelectedNetwork.id] });
|
||||||
|
}
|
||||||
|
}, [chain?.id, setTargetNetwork, targetNetwork.id]);
|
||||||
|
|
||||||
|
return useMemo(() => ({ targetNetwork }), [targetNetwork]);
|
||||||
|
}
|
||||||
115
packages/nextjs/hooks/scaffold-eth/useTransactor.tsx
Normal file
115
packages/nextjs/hooks/scaffold-eth/useTransactor.tsx
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Hash, SendTransactionParameters, TransactionReceipt, WalletClient } from "viem";
|
||||||
|
import { Config, useWalletClient } from "wagmi";
|
||||||
|
import { getPublicClient } from "wagmi/actions";
|
||||||
|
import { SendTransactionMutate } from "wagmi/query";
|
||||||
|
import scaffoldConfig from "~~/scaffold.config";
|
||||||
|
import { wagmiConfig } from "~~/services/web3/wagmiConfig";
|
||||||
|
import { AllowedChainIds, getBlockExplorerTxLink, notification } from "~~/utils/scaffold-eth";
|
||||||
|
import { TransactorFuncOptions, getParsedErrorWithAllAbis } from "~~/utils/scaffold-eth/contract";
|
||||||
|
|
||||||
|
type TransactionFunc = (
|
||||||
|
tx: (() => Promise<Hash>) | Parameters<SendTransactionMutate<Config, undefined>>[0],
|
||||||
|
options?: TransactorFuncOptions,
|
||||||
|
) => Promise<Hash | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom notification content for TXs.
|
||||||
|
*/
|
||||||
|
const TxnNotification = ({ message, blockExplorerLink }: { message: string; blockExplorerLink?: string }) => {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col ml-1 cursor-default`}>
|
||||||
|
<p className="my-0">{message}</p>
|
||||||
|
{blockExplorerLink && blockExplorerLink.length > 0 ? (
|
||||||
|
<a href={blockExplorerLink} target="_blank" rel="noreferrer" className="block link">
|
||||||
|
check out transaction
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs Transaction passed in to returned function showing UI feedback.
|
||||||
|
* @param _walletClient - Optional wallet client to use. If not provided, will use the one from useWalletClient.
|
||||||
|
* @returns function that takes in transaction function as callback, shows UI feedback for transaction and returns a promise of the transaction hash
|
||||||
|
*/
|
||||||
|
export const useTransactor = (_walletClient?: WalletClient): TransactionFunc => {
|
||||||
|
let walletClient = _walletClient;
|
||||||
|
const { data } = useWalletClient();
|
||||||
|
if (walletClient === undefined && data) {
|
||||||
|
walletClient = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: TransactionFunc = async (tx, options) => {
|
||||||
|
if (!walletClient) {
|
||||||
|
notification.error("Cannot access account");
|
||||||
|
console.error("⚡️ ~ file: useTransactor.tsx ~ error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let notificationId = null;
|
||||||
|
let transactionHash: Hash | undefined = undefined;
|
||||||
|
let transactionReceipt: TransactionReceipt | undefined;
|
||||||
|
let blockExplorerTxURL = "";
|
||||||
|
let chainId: number = scaffoldConfig.targetNetworks[0].id;
|
||||||
|
try {
|
||||||
|
chainId = await walletClient.getChainId();
|
||||||
|
// Get full transaction from public client
|
||||||
|
const publicClient = getPublicClient(wagmiConfig);
|
||||||
|
|
||||||
|
notificationId = notification.loading(<TxnNotification message="Awaiting for user confirmation" />);
|
||||||
|
if (typeof tx === "function") {
|
||||||
|
// Tx is already prepared by the caller
|
||||||
|
const result = await tx();
|
||||||
|
transactionHash = result;
|
||||||
|
} else if (tx != null) {
|
||||||
|
transactionHash = await walletClient.sendTransaction(tx as SendTransactionParameters);
|
||||||
|
} else {
|
||||||
|
throw new Error("Incorrect transaction passed to transactor");
|
||||||
|
}
|
||||||
|
notification.remove(notificationId);
|
||||||
|
|
||||||
|
blockExplorerTxURL = chainId ? getBlockExplorerTxLink(chainId, transactionHash) : "";
|
||||||
|
|
||||||
|
notificationId = notification.loading(
|
||||||
|
<TxnNotification message="Waiting for transaction to complete." blockExplorerLink={blockExplorerTxURL} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
transactionReceipt = await publicClient.waitForTransactionReceipt({
|
||||||
|
hash: transactionHash,
|
||||||
|
confirmations: options?.blockConfirmations,
|
||||||
|
});
|
||||||
|
notification.remove(notificationId);
|
||||||
|
|
||||||
|
if (transactionReceipt.status === "reverted") throw new Error("Transaction reverted");
|
||||||
|
|
||||||
|
notification.success(
|
||||||
|
<TxnNotification message="Transaction completed successfully!" blockExplorerLink={blockExplorerTxURL} />,
|
||||||
|
{
|
||||||
|
icon: "🎉",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options?.onBlockConfirmation) options.onBlockConfirmation(transactionReceipt);
|
||||||
|
} catch (error: any) {
|
||||||
|
if (notificationId) {
|
||||||
|
notification.remove(notificationId);
|
||||||
|
}
|
||||||
|
console.error("⚡️ ~ file: useTransactor.ts ~ error", error);
|
||||||
|
const message = getParsedErrorWithAllAbis(error, chainId as AllowedChainIds);
|
||||||
|
|
||||||
|
// if receipt was reverted, show notification with block explorer link and return error
|
||||||
|
if (transactionReceipt?.status === "reverted") {
|
||||||
|
notification.error(<TxnNotification message={message} blockExplorerLink={blockExplorerTxURL} />);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.error(message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return transactionHash;
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
6
packages/nextjs/next-env.d.ts
vendored
Normal file
6
packages/nextjs/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="next" />
|
||||||
|
/// <reference types="next/image-types/global" />
|
||||||
|
/// <reference path="./.next/types/routes.d.ts" />
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
29
packages/nextjs/next.config.ts
Normal file
29
packages/nextjs/next.config.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
reactStrictMode: true,
|
||||||
|
devIndicators: false,
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
eslint: {
|
||||||
|
ignoreDuringBuilds: true,
|
||||||
|
},
|
||||||
|
webpack: config => {
|
||||||
|
config.resolve.fallback = { fs: false, net: false, tls: false };
|
||||||
|
config.externals.push("pino-pretty", "lokijs", "encoding");
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const isIpfs = process.env.NEXT_PUBLIC_IPFS_BUILD === "true";
|
||||||
|
|
||||||
|
if (isIpfs) {
|
||||||
|
nextConfig.output = "export";
|
||||||
|
nextConfig.trailingSlash = true;
|
||||||
|
nextConfig.images = {
|
||||||
|
unoptimized: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
62
packages/nextjs/package.json
Normal file
62
packages/nextjs/package.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"name": "@se-2/nextjs",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build",
|
||||||
|
"check-types": "tsc --noEmit --incremental",
|
||||||
|
"dev": "next dev",
|
||||||
|
"format": "prettier --write . '!(node_modules|.next|contracts)/**/*'",
|
||||||
|
"ipfs": "NEXT_PUBLIC_IPFS_BUILD=true yarn build && yarn bgipfs upload config init -u https://upload.bgipfs.com && CID=$(yarn bgipfs upload out | grep -o 'CID: [^ ]*' | cut -d' ' -f2) && [ ! -z \"$CID\" ] && echo '🚀 Upload complete! Your site is now available at: https://community.bgipfs.com/ipfs/'$CID || echo '❌ Upload failed'",
|
||||||
|
"lint": "next lint",
|
||||||
|
"serve": "next start",
|
||||||
|
"start": "next dev",
|
||||||
|
"vercel": "vercel --build-env YARN_ENABLE_IMMUTABLE_INSTALLS=false --build-env ENABLE_EXPERIMENTAL_COREPACK=1 --build-env VERCEL_TELEMETRY_DISABLED=1",
|
||||||
|
"vercel:login": "vercel login",
|
||||||
|
"vercel:yolo": "vercel --build-env YARN_ENABLE_IMMUTABLE_INSTALLS=false --build-env ENABLE_EXPERIMENTAL_COREPACK=1 --build-env NEXT_PUBLIC_IGNORE_BUILD_ERROR=true --build-env VERCEL_TELEMETRY_DISABLED=1"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@heroicons/react": "~2.1.5",
|
||||||
|
"@rainbow-me/rainbowkit": "2.2.9",
|
||||||
|
"@react-native-async-storage/async-storage": "~2.2.0",
|
||||||
|
"@scaffold-ui/components": "^0.1.7",
|
||||||
|
"@scaffold-ui/debug-contracts": "^0.1.6",
|
||||||
|
"@scaffold-ui/hooks": "^0.1.5",
|
||||||
|
"@tanstack/react-query": "~5.59.15",
|
||||||
|
"blo": "~1.2.0",
|
||||||
|
"burner-connector": "0.0.20",
|
||||||
|
"daisyui": "5.0.9",
|
||||||
|
"kubo-rpc-client": "~5.0.2",
|
||||||
|
"next": "~15.2.8",
|
||||||
|
"next-nprogress-bar": "~2.3.13",
|
||||||
|
"next-themes": "~0.3.0",
|
||||||
|
"qrcode.react": "~4.0.1",
|
||||||
|
"react": "~19.2.3",
|
||||||
|
"react-dom": "~19.2.3",
|
||||||
|
"react-hot-toast": "~2.4.0",
|
||||||
|
"usehooks-ts": "~3.1.0",
|
||||||
|
"viem": "2.39.0",
|
||||||
|
"wagmi": "2.19.5",
|
||||||
|
"zustand": "~5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "4.0.15",
|
||||||
|
"@trivago/prettier-plugin-sort-imports": "~4.3.0",
|
||||||
|
"@types/node": "~18.19.50",
|
||||||
|
"@types/react": "~19.0.7",
|
||||||
|
"abitype": "1.0.6",
|
||||||
|
"autoprefixer": "~10.4.20",
|
||||||
|
"bgipfs": "~0.0.12",
|
||||||
|
"eslint": "~9.23.0",
|
||||||
|
"eslint-config-next": "~15.2.3",
|
||||||
|
"eslint-config-prettier": "~10.1.1",
|
||||||
|
"eslint-plugin-prettier": "~5.2.4",
|
||||||
|
"postcss": "~8.4.45",
|
||||||
|
"prettier": "~3.5.3",
|
||||||
|
"tailwindcss": "4.1.3",
|
||||||
|
"type-fest": "~4.26.1",
|
||||||
|
"typescript": "~5.8.2",
|
||||||
|
"vercel": "~39.1.3"
|
||||||
|
},
|
||||||
|
"packageManager": "yarn@3.2.3"
|
||||||
|
}
|
||||||
5
packages/nextjs/postcss.config.js
Normal file
5
packages/nextjs/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
BIN
packages/nextjs/public/favicon.png
Normal file
BIN
packages/nextjs/public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
BIN
packages/nextjs/public/hero.png
Normal file
BIN
packages/nextjs/public/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user