Initial commit with 🏗️ Scaffold-ETH 2 @ 1.0.5

This commit is contained in:
han
2026-01-10 18:17:37 +07:00
commit 98751c5b87
165 changed files with 29073 additions and 0 deletions

View File

@@ -0,0 +1,112 @@
---
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
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.
They live under `packages/nextjs/components/scaffold-eth`.
Find the relevant information from the documentation and the codebase. Think step by step before answering the question.

1
.cursorignore Normal file
View File

@@ -0,0 +1 @@
*

43
.github/workflows/lint.yaml vendored Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
yarn lint-staged --verbose

21
.lintstagedrc.js Normal file
View 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],
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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
View 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
View 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
View 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.

300
README.md Normal file
View File

@@ -0,0 +1,300 @@
# 🏗 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.
![Debug Contracts tab](https://github.com/scaffold-eth/scaffold-eth-2/assets/55535804/b237af0c-5027-4849-a5c1-2e31495cccb1)
## 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: 🎟 Tokenization
![readme](https://raw.githubusercontent.com/scaffold-eth/se-2-challenges/challenge-tokenization/extension/packages/nextjs/public/hero.png)
📚 This tutorial is meant for developers that already understand the [ 🖍️ basics ](https://www.youtube.com/watch?v=MlJPjJQZtC8).
🧑‍🏫 If you would like a more gentle introduction for developers, watch our 15 video [🎥 Web2 to Web3](https://www.youtube.com/playlist?list=PLJz1HruEnenAf80uOfDwBPqaliJkjKg69) series.
---
🎫 Tokenize unique items:
👷‍♀️ You'll compile and deploy your first smart contracts. Then, you'll use a template NextJS app full of important Ethereum components and hooks. Finally, you'll deploy a non-fungible token to a public network where you can send it to anyone! 🚀
🌟 The final deliverable is an app that lets users mint and transfer NFTs and understand onchain ownership. Deploy your contracts to a testnet, then build and upload 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/+Y2vqXZZ_pEFhMGMx)!
<details markdown='1'><summary>❓ Wondering what "tokenization" means?</summary>
Tokenization is like giving anything a digital passport you can carry in your wallet. It proves who owns it, lets you hand it off in a click, and lets apps recognize it automatically. In this challenge you'll mint ERC-721 tokens (NFTs): each token is one-of-one, owned by a single address (what `ownerOf(tokenId)` returns). Transfers are atomic, instant, traceable, and run by code.
- **Real-World Assets (RWAs)**: Think stocks, bonds, gold, and real estate. If these are tokenized, the token acts as a digital claim or registry entry. For real-world effect, an issuer/custodian or legal framework must link onchain transfers to off-chain rights; without that bridge, it's a collectible, not a legal transfer.
- Who has the right to tokenize?: The legitimate owner or authorized issuer. If you don't control the thing, your token is unofficial fan art, not enforceable rights.
- **Blockchain-native assets**: Some things are born onchain; art, game items, ENS names, fully-onchain media, and even concert tickets designed for onchain verification. Here, the token is the thing. That makes them globally transferable, permissionless, and composable. Plug them into marketplaces, auctions, lending, or games. As the world moves onchain we'll see more native experiences like tickets that verify at the door, auto-split royalties on resales, and unlock perks across apps.
</details>
---
🚫 **"Wait, aren't NFTs just monkey JPEGS?"** That couldn't be further from the truth! While some NFTs are nothing more than metadata pointing to an image, the important part is to realize how assets on Ethereum unlock composability.
🌐 Look at how ENS is improving upon the Domain Name System (DNS aka website addresses). These are just NFTs but they carry a lot of usefulness. The same usefulness that is unlocked by not having to remember to type 64.233.180.138 to get to Google.com is unlocked by these ENS names but the usefulness goes further than that. You can use these ENS names to alias wallet addresses as well. Check out the ENS record for [vitalik.eth](https://app.ens.domains/vitalik.eth) to see how there are many records - some for addresses, some for content. Try going to [vitalik.eth.limo](https://vitalik.eth.limo) and see how the contentHash record resolves to his personal webpage.
🦄 Or checkout how Uniswap V3 is [using NFTs](https://uniswapv3book.com/milestone_6/nft-manager.html) to track each liquidity providers portion in the pool. Don't worry if you don't understand all those words yet, just realize that this is a big deal to represent ownership of assets this way.
♻️ Even if the NFT itself is completely inert and reflects complete speculation on a "worthless" image, you can still use that NFT in other smart contracts to build interesting projects like what they have done at [PunkStrategy](https://www.punkstrategy.fun/) where you can buy tokens in a protocol that gives you fractional exposure to CryptoPunk NFTs while also having part in a strategy that automatically uses profit from token sales to buy the lowest valued Punk on the market, then relists it at a 20% premium - over and over again, forever!
🔓 Composability unlocks all of this and so much more!
🚀 Are you ready? You're about to mint something the entire internet can see, trade, and build on.
<details><summary>But wait! Wait does "NFT" stand for?</summary>
NFT stands for Non-Fungible Token. Non-fungible means that each token is unique. Fungible tokens (often represented as ERC-20s) are all the same. Each one looks and acts the same and has the same abilities. With NFTs there is something unique about each token. That is why they do well to express unique names, images (monkey JPEGS), DEX liquidity positions, and maybe some day real estate parcels.
</details>
## Checkpoint 0: 📦 Environment 📚
> Start your local network (a blockchain emulator in your computer):
```sh
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](http://localhost:3000) to see the app.
---
⚠️ 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: ⛽️ Gas & Wallets 👛
> ⛽️ Gas is the tiny fee that powers your transactions, like postage for the blockchain. Grab test funds from the faucet so you can interact onchain.
![gas&wallet](https://github.com/scaffold-eth/se-2-challenges/assets/80153681/912d0d4b-db34-49d3-bd7d-7ca0ab18eb66)
> 🦊 At first, **don't** connect MetaMask. If you are already connected, click **Disconnect**:
<p>
<img src="https://github.com/scaffold-eth/se-2-challenges/assets/80153681/2c7a1e40-50ad-4c20-ba3e-a56eff4b892b" width="33%" />
<img src="https://github.com/scaffold-eth/se-2-challenges/assets/80153681/1bcf9752-e8ae-4db6-a0a6-5dc774abe46c" width="33%" />
</p>
> 🔥 We'll use burner wallets on localhost. They're disposable wallets that auto-sign transactions so you can keep building.
> 👛 Explore burner wallets in 🏗 Scaffold-ETH 2: open an incognito window and visit http://localhost:3000. You'll see a totally new address in the top-right. Copy it and send test funds from your first window using the **Faucet** button (bottom-left):
![icognito&webBrowser](https://github.com/user-attachments/assets/bf1cbb3e-b014-44b1-9b19-83d831abcb19)
> 👨🏻‍🚒 When you close the incognito window, that account is gone forever. Burner wallets are perfect for local dev; you'll switch to a permanent wallet on public networks.
---
## Checkpoint 2: 🖨 Minting
🦓 In this challenge we will be minting (or creating) collectible Buffalo, Zebra and Rhino NFTs. Their metadata is stored on [IPFS](https://ipfs.tech/). These won't feign any real value but what price can you put on your own understanding of intricate blockchain ownership designs? Face it, it's **priceless!**
> ✏️ Time to create something new. Click **MINT NFT** in the `My NFTs` tab to mint an ERC-721 token; your unique, onchain collectible.
![image](https://github.com/scaffold-eth/se-2-challenges/assets/80153681/74cf02f2-4c1b-4278-9841-f19f668e0b1e)
👀 You should see your NFTs start to show up:
![image](https://github.com/scaffold-eth/se-2-challenges/assets/80153681/63dabceb-ad42-4c09-8e5d-a0139939e32d)
👛 Open an incognito window and navigate to http://localhost:3000.
🎟 Try a transfer! Send a token to the incognito window address using the UI:
![image](https://github.com/scaffold-eth/se-2-challenges/assets/80153681/3b92fb50-d43f-48a8-838c-c45c443b0b71)
👛 Try to mint an NFT from the incognito window.
> Can you mint an NFT with no funds in this address? You might need to grab funds from the faucet to pay for the gas!
🕵🏻‍♂️ Inspect the `Debug Contracts` tab to figure out what address is the owner of a specific token (`ownerOf(tokenId)`) in `YourCollectible`.
🔏 You can also check out your smart contract `YourCollectible.sol` in `packages/hardhat/contracts`.
💼 Take a quick look at your deploy script `01_deploy_your_collectible.ts` in `packages/hardhat/deploy`.
### Onchain Ownership 101
- **What is ownership?** Onchain ownership of a unique item is whoever `ownerOf(tokenId)` returns.
- **Who can transfer?** Only the owner or an approved operator can transfer a token.
- **What happens on transfer?** Ownership moves atomically to the new address and a `Transfer` event is emitted.
- **How do approvals work?** Use `approve` for a single token or `setApprovalForAll` for an operator across all your tokens.
- **How to count holdings?** `balanceOf(address)` shows how many unique tokens an address owns.
📝 If you want to edit the frontend, navigate to `packages/nextjs/app` and open the specific page you want to modify. For instance: `/myNFTs/page.tsx`. For guidance on [routing](https://nextjs.org/docs/app/building-your-application/routing/defining-routes) and configuring [pages/layouts](https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts) checkout the Next.js documentation.
---
## Checkpoint 3: 💾 Deploy your contract! 🛰
🛰 Ready to go public (on testnet)? Let's ship it.
> Change the defaultNetwork in `packages/hardhat/hardhat.config.ts` to `sepolia`.
![chall-0-hardhat-config](https://github.com/scaffold-eth/se-2-challenges/assets/55535804/f94b47d8-aa51-46eb-9c9e-7536559a5d45)
🔐 Generate a deployer address with `yarn generate`. This creates a unique deployer address and saves the mnemonic locally. You will be prompted to enter a password, which will be used to encrypt your private key. **Make sure to remember this password, as you'll need it for future deployments and account queries.**
> This local account will deploy your contracts, allowing you to avoid entering a personal private key.
![chall-0-yarn-generate](https://github.com/scaffold-eth/se-2-challenges/assets/2486142/133f5701-e575-4cc2-904f-cdc83ae86d94)
👩‍🚀 Use `yarn account` to view your deployer account balances.
![chall-0-yarn-account](https://github.com/scaffold-eth/se-2-challenges/assets/2486142/c34df8c9-9793-4a76-849b-170fae7fd0f0)
⛽️ Fund your deployer with testnet ETH from a faucet or another wallet so it can pay gas.
> Some popular Sepolia faucets are the [Alchemy Faucet](https://sepoliafaucet.com/), [Infura Faucet](https://www.infura.io/faucet/sepolia), and [Google Cloud Faucet](https://cloud.google.com/application/web3/faucet/ethereum/sepolia).
> ⚔️ Side Quest: Keep a 🧑‍🎤 [punkwallet.io](https://punkwallet.io) on your phone's home screen and keep it loaded with testnet eth. 🧙‍♂️ You'll look like a wizard when you can fund your deployer address from your phone in seconds.
🚀 Deploy your NFT smart contract with `yarn deploy`.
> 💬 Hint: You can set the `defaultNetwork` in `hardhat.config.ts` to `sepolia` **OR** you can `yarn deploy --network sepolia`.
---
## Checkpoint 4: 🚢 Ship your frontend! 🚁
> ✏️ Tune your frontend for the right chain. In `packages/nextjs/scaffold.config.ts`, set `targetNetwork` to `chains.sepolia`:
![chall-0-scaffold-config](https://github.com/scaffold-eth/se-2-challenges/assets/12072395/ff03bda0-66c6-4907-a9ad-bc8587da8036)
> Confirm the network badge in the UI at http://localhost:3000 shows Sepolia:
![image](https://github.com/scaffold-eth/se-2-challenges/assets/80153681/50eef1f7-e1a3-4b3b-87e2-59c19362c4ff)
> 🦊 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/`)
![image](https://github.com/scaffold-eth/se-2-challenges/assets/80153681/f582d311-9b57-4503-8143-bac60346ea33)
🚀 Deploy your NextJS app
```sh
yarn vercel
```
> 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.
⚠️ Run the automated testing function to make sure your app passes
```sh
yarn test
```
#### 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 **SpeedRunEthereum**.
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 5: 📜 Contract Verification
You can verify your smart contract on Etherscan by running (`yarn verify --network network_name`):
```sh
yarn verify --network sepolia
```
Verifying your contract is important for enabling others to be able to look at your contract and verify that it isn't a scam.
> If it says your contract is already verified, that's fine. It just means Etherscan recognized the contract bytecode as being the same as one that was already deployed and verified. Copy the address of `YourCollectible.sol` and search it on Sepolia Etherscan to grab the URL you'll submit for this challenge.
## Checkpoint 6: 💪 Flex!
👩‍❤️‍👨 Share your public url with a friend and ask them for their address to send them a collectible:
![gif](https://github.com/scaffold-eth/se-2-challenges/assets/80153681/547612f6-97b9-4eb3-ab6d-9b6d2c0ac769)
## ⚔️ Side Quests
### 🦊 MetaMask NFTs
> 🎫 Want to see your new NFTs? Check your MetaMask extension! Your minted NFTs should appear in the NFTs section.
> 🌐 **Important**: Make sure you switch to the Sepolia testnet in MetaMask before checking for your NFTs, since that's where you deployed your contract.
> 🔍 Make sure you have minted some NFTs on your Vercel page, then open your MetaMask extension and navigate to the "NFTs" tab.
![image](https://raw.githubusercontent.com/scaffold-eth/se-2-challenges/challenge-tokenization/extension/packages/nextjs/public/nfts.png)
> You can see your collection of shiny new NFTs directly in your wallet!
(It can take a while before they show up in MetaMask, but they should appear in the NFTs section once detected.)
> 🔧 **Not seeing your NFTs?** Sometimes MetaMask doesn't automatically detect new NFTs. You can manually add them by clicking "Import NFT" in the NFTs tab and entering network, your contract address and token ID.
![image](https://raw.githubusercontent.com/scaffold-eth/se-2-challenges/challenge-tokenization/extension/packages/nextjs/public/import-nft.png)
---
> 🏃 Head to your next challenge [here](https://speedrunethereum.com/challenge/decentralized-staking).
> 💬 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
View 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"
}
}

View 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
View 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

View 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
}
}
]
}

View File

@@ -0,0 +1,49 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
contract YourCollectible is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable {
uint256 public tokenIdCounter;
constructor() ERC721("YourCollectible", "YCB") Ownable(msg.sender) {}
function _baseURI() internal pure override returns (string memory) {
return "https://ipfs.io/ipfs/";
}
function mintItem(address to, string memory uri) public returns (uint256) {
tokenIdCounter++;
uint256 tokenId = tokenIdCounter;
_safeMint(to, tokenId);
_setTokenURI(tokenId, uri);
return tokenId;
}
// Override functions from OpenZeppelin ERC721, ERC721Enumerable and ERC721URIStorage
function _update(
address to,
uint256 tokenId,
address auth
) internal override(ERC721, ERC721Enumerable) returns (address) {
return super._update(to, tokenId, auth);
}
function _increaseBalance(address account, uint128 value) internal override(ERC721, ERC721Enumerable) {
super._increaseBalance(account, value);
}
function tokenURI(uint256 tokenId) public view override(ERC721, ERC721URIStorage) returns (string memory) {
return super.tokenURI(tokenId);
}
function supportsInterface(
bytes4 interfaceId
) public view override(ERC721, ERC721Enumerable, ERC721URIStorage) returns (bool) {
return super.supportsInterface(interfaceId);
}
}

View File

@@ -0,0 +1,43 @@
import { HardhatRuntimeEnvironment } from "hardhat/types";
import { DeployFunction } from "hardhat-deploy/types";
import { Contract } from "ethers";
/**
* Deploys a contract named "YourCollectible" using the deployer account and
* constructor arguments set to the deployer address
*
* @param hre HardhatRuntimeEnvironment object.
*/
const deployYourCollectible: 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("YourCollectible", {
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 to interact with it after deploying.
const yourCollectible = await hre.ethers.getContract<Contract>("YourCollectible", deployer);
};
export default deployYourCollectible;
// Tags are useful if you have multiple deploy files and only want to run one of them.
// e.g. yarn deploy --tags YourCollectible
deployYourCollectible.tags = ["YourCollectible"];

View 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",
},
],
},
},
]);

View 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 || "oKxs-03sij-U_N0iOlrSsZFr29-IqbuF";
// 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;

View 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"
}
}

View 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;
});

View 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;

View 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;
});

View 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;
});

View 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;
});

View 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);

View File

@@ -0,0 +1,2 @@
# Write tests for your smart contract in this directory
# Example: YourContract.ts

View File

@@ -0,0 +1,58 @@
//
// This script executes when you run 'yarn test'
//
import { ethers } from "hardhat";
import { expect } from "chai";
import { YourCollectible } from "../typechain-types";
describe("🚩 Challenge: 🎟 Tokenization 🤓", function () {
let myContract: YourCollectible;
describe("YourCollectible", function () {
const contractAddress = process.env.CONTRACT_ADDRESS;
let contractArtifact: string;
if (contractAddress) {
// For the autograder
contractArtifact = `contracts/download-${contractAddress}.sol:YourCollectible`;
} else {
contractArtifact = "contracts/YourCollectible.sol:YourCollectible";
}
it("Should deploy the contract", async function () {
const YourCollectible = await ethers.getContractFactory(contractArtifact);
myContract = await YourCollectible.deploy();
console.log("\t", " 🛰 Contract deployed on", await myContract.getAddress());
});
describe("mintItem()", function () {
it("Should be able to mint an NFT", async function () {
const [owner] = await ethers.getSigners();
console.log("\t", " 🧑‍🏫 Tester Address: ", owner.address);
const startingBalance = await myContract.balanceOf(owner.address);
console.log("\t", " ⚖️ Starting balance: ", Number(startingBalance));
console.log("\t", " 🔨 Minting...");
const mintResult = await myContract.mintItem(owner.address, "QmfVMAmNM1kDEBYrC2TPzQDoCRFH6F5tE1e9Mr4FkkR5Xr");
console.log("\t", " 🏷 mint tx: ", mintResult.hash);
console.log("\t", " ⏳ Waiting for confirmation...");
const txResult = await mintResult.wait();
expect(txResult?.status).to.equal(1);
console.log("\t", " 🔎 Checking new balance: ", Number(startingBalance));
expect(await myContract.balanceOf(owner.address)).to.equal(startingBalance + 1n);
});
it("Should track tokens of owner by index", async function () {
const [owner] = await ethers.getSigners();
const startingBalance = await myContract.balanceOf(owner.address);
const token = await myContract.tokenOfOwnerByIndex(owner.address, startingBalance - 1n);
expect(token).to.greaterThan(0);
});
});
});
});

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "es2020",
"module": "commonjs",
"esModuleInterop": true,
"resolveJsonModule": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true
}
}

View 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
View 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

View 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")],
};

View File

@@ -0,0 +1,14 @@
"use server";
import { ipfsClient } from "~~/utils/tokenization/ipfs";
export async function POST(request: Request) {
try {
const body = await request.json();
const res = await ipfsClient.add(JSON.stringify(body));
return Response.json(res);
} catch (error) {
console.log("Error adding to ipfs", error);
return Response.json({ error: "Error adding to ipfs" });
}
}

View File

@@ -0,0 +1,12 @@
import { getNFTMetadataFromIPFS } from "~~/utils/tokenization/ipfs";
export async function POST(request: Request) {
try {
const { ipfsHash } = await request.json();
const res = await getNFTMetadataFromIPFS(ipfsHash);
return Response.json(res);
} catch (error) {
console.log("Error getting metadata from ipfs", error);
return Response.json({ error: "Error getting metadata from ipfs" });
}
}

View File

@@ -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>
);
};

View File

@@ -0,0 +1,36 @@
import { BackButton } from "./BackButton";
import { ContractTabs } from "./ContractTabs";
import { Address as AddressType } from "viem";
import { Address, Balance } from "~~/components/scaffold-eth";
export const AddressComponent = ({
address,
contractData,
}: {
address: AddressType;
contractData: { bytecode: string; assembly: string } | null;
}) => {
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 />
<div className="flex gap-1 items-center">
<span className="font-bold text-sm">Balance:</span>
<Balance address={address} className="text" />
</div>
</div>
</div>
</div>
</div>
</div>
<ContractTabs address={address} contractData={contractData} />
</div>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View 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} />}
</>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -0,0 +1,71 @@
import { TransactionHash } from "./TransactionHash";
import { formatEther } from "viem";
import { Address } from "~~/components/scaffold-eth";
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 />
</td>
<td className="w-2/12 md:py-4">
{!receipt?.contractAddress ? (
tx.to && <Address address={tx.to} size="sm" onlyEnsOrAddress />
) : (
<div className="relative">
<Address address={receipt.contractAddress} size="sm" onlyEnsOrAddress />
<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>
);
};

View 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";

View 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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -0,0 +1,152 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Hash, Transaction, TransactionReceipt, formatEther, formatUnits } from "viem";
import { hardhat } from "viem/chains";
import { usePublicClient } from "wagmi";
import { Address } from "~~/components/scaffold-eth";
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 />
</td>
</tr>
<tr>
<td>
<strong>To:</strong>
</td>
<td>
{!receipt?.contractAddress ? (
transaction.to && <Address address={transaction.to} format="long" onlyEnsOrAddress />
) : (
<span>
Contract Creation:
<Address address={receipt.contractAddress} format="long" onlyEnsOrAddress />
</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;

View File

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useMemo } from "react";
import { useSessionStorage } from "usehooks-ts";
import { BarsArrowUpIcon } from "@heroicons/react/20/solid";
import { ContractUI } from "~~/app/debug/_components/contract";
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 => (
<ContractUI
key={contractName}
contractName={contractName}
className={contractName === selectedContract ? "" : "hidden"}
/>
))}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { Dispatch, SetStateAction } from "react";
import { Tuple } from "./Tuple";
import { TupleArray } from "./TupleArray";
import { AbiParameter } from "abitype";
import {
AddressInput,
Bytes32Input,
BytesInput,
InputBase,
IntegerInput,
IntegerVariant,
} from "~~/components/scaffold-eth";
import { AbiParameterTuple } from "~~/utils/scaffold-eth/contract";
type ContractInputProps = {
setForm: Dispatch<SetStateAction<Record<string, any>>>;
form: Record<string, any> | undefined;
stateObjectKey: string;
paramType: AbiParameter;
};
/**
* Generic Input component to handle input's based on their function param type
*/
export const ContractInput = ({ setForm, form, stateObjectKey, paramType }: ContractInputProps) => {
const inputProps = {
name: stateObjectKey,
value: form?.[stateObjectKey],
placeholder: paramType.name ? `${paramType.type} ${paramType.name}` : paramType.type,
onChange: (value: any) => {
setForm(form => ({ ...form, [stateObjectKey]: value }));
},
};
const renderInput = () => {
switch (paramType.type) {
case "address":
return <AddressInput {...inputProps} />;
case "bytes32":
return <Bytes32Input {...inputProps} />;
case "bytes":
return <BytesInput {...inputProps} />;
case "string":
return <InputBase {...inputProps} />;
case "tuple":
return (
<Tuple
setParentForm={setForm}
parentForm={form}
abiTupleParameter={paramType as AbiParameterTuple}
parentStateObjectKey={stateObjectKey}
/>
);
default:
// Handling 'int' types and 'tuple[]' types
if (paramType.type.includes("int") && !paramType.type.includes("[")) {
return <IntegerInput {...inputProps} variant={paramType.type as IntegerVariant} />;
} else if (paramType.type.startsWith("tuple[")) {
return (
<TupleArray
setParentForm={setForm}
parentForm={form}
abiTupleParameter={paramType as AbiParameterTuple}
parentStateObjectKey={stateObjectKey}
/>
);
} else {
return <InputBase {...inputProps} />;
}
}
};
return (
<div className="flex flex-col gap-1.5 w-full">
<div className="flex items-center ml-2">
{paramType.name && <span className="text-xs font-medium mr-2 leading-none">{paramType.name}</span>}
<span className="block text-xs font-extralight leading-none">{paramType.type}</span>
</div>
{renderInput()}
</div>
);
};

View File

@@ -0,0 +1,43 @@
import { Abi, AbiFunction } from "abitype";
import { ReadOnlyFunctionForm } from "~~/app/debug/_components/contract";
import { Contract, ContractName, GenericContract, InheritedFunctions } from "~~/utils/scaffold-eth/contract";
export const ContractReadMethods = ({ deployedContractData }: { deployedContractData: Contract<ContractName> }) => {
if (!deployedContractData) {
return null;
}
const functionsToDisplay = (
((deployedContractData.abi || []) as Abi).filter(part => part.type === "function") as AbiFunction[]
)
.filter(fn => {
const isQueryableWithParams =
(fn.stateMutability === "view" || fn.stateMutability === "pure") && fn.inputs.length > 0;
return isQueryableWithParams;
})
.map(fn => {
return {
fn,
inheritedFrom: ((deployedContractData as GenericContract)?.inheritedFunctions as InheritedFunctions)?.[fn.name],
};
})
.sort((a, b) => (b.inheritedFrom ? b.inheritedFrom.localeCompare(a.inheritedFrom) : 1));
if (!functionsToDisplay.length) {
return <>No read methods</>;
}
return (
<>
{functionsToDisplay.map(({ fn, inheritedFrom }) => (
<ReadOnlyFunctionForm
abi={deployedContractData.abi as Abi}
contractAddress={deployedContractData.address}
abiFunction={fn}
key={fn.name}
inheritedFrom={inheritedFrom}
/>
))}
</>
);
};

View File

@@ -0,0 +1,104 @@
"use client";
// @refresh reset
import { useReducer } from "react";
import { ContractReadMethods } from "./ContractReadMethods";
import { ContractVariables } from "./ContractVariables";
import { ContractWriteMethods } from "./ContractWriteMethods";
import { Address, Balance } from "~~/components/scaffold-eth";
import { useDeployedContractInfo, useNetworkColor } 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, className = "" }: ContractUIProps) => {
const [refreshDisplayVariables, triggerRefreshDisplayVariables] = useReducer(value => !value, false);
const { targetNetwork } = useTargetNetwork();
const { data: deployedContractData, isLoading: deployedContractLoading } = useDeployedContractInfo({ contractName });
const networkColor = useNetworkColor();
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 (
<div className={`grid grid-cols-1 lg:grid-cols-6 px-6 lg:px-10 lg:gap-12 w-full max-w-7xl my-0 ${className}`}>
<div className="col-span-5 grid grid-cols-1 lg:grid-cols-3 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">
<div className="flex">
<div className="flex flex-col gap-1">
<span className="font-bold">{contractName}</span>
<Address address={deployedContractData.address} onlyEnsOrAddress />
<div className="flex gap-1 items-center">
<span className="font-bold text-sm">Balance:</span>
<Balance address={deployedContractData.address} className="px-0 h-1.5 min-h-[0.375rem]" />
</div>
</div>
</div>
{targetNetwork && (
<p className="my-0 text-sm">
<span className="font-bold">Network</span>:{" "}
<span style={{ color: networkColor }}>{targetNetwork.name}</span>
</p>
)}
</div>
<div className="bg-base-300 rounded-3xl px-6 lg:px-8 py-4 shadow-lg shadow-base-300">
<ContractVariables
refreshDisplayVariables={refreshDisplayVariables}
deployedContractData={deployedContractData}
/>
</div>
</div>
<div className="col-span-1 lg:col-span-2 flex flex-col gap-6">
<div className="z-10">
<div className="bg-base-100 rounded-3xl shadow-md shadow-secondary border border-base-300 flex flex-col mt-10 relative">
<div className="h-[5rem] w-[5.5rem] bg-base-300 absolute self-start rounded-[22px] -top-[38px] -left-[1px] -z-10 py-[0.65rem] shadow-lg shadow-base-300">
<div className="flex items-center justify-center space-x-2">
<p className="my-0 text-sm">Read</p>
</div>
</div>
<div className="p-5 divide-y divide-base-300">
<ContractReadMethods deployedContractData={deployedContractData} />
</div>
</div>
</div>
<div className="z-10">
<div className="bg-base-100 rounded-3xl shadow-md shadow-secondary border border-base-300 flex flex-col mt-10 relative">
<div className="h-[5rem] w-[5.5rem] bg-base-300 absolute self-start rounded-[22px] -top-[38px] -left-[1px] -z-10 py-[0.65rem] shadow-lg shadow-base-300">
<div className="flex items-center justify-center space-x-2">
<p className="my-0 text-sm">Write</p>
</div>
</div>
<div className="p-5 divide-y divide-base-300">
<ContractWriteMethods
deployedContractData={deployedContractData}
onChange={triggerRefreshDisplayVariables}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,50 @@
import { DisplayVariable } from "./DisplayVariable";
import { Abi, AbiFunction } from "abitype";
import { Contract, ContractName, GenericContract, InheritedFunctions } from "~~/utils/scaffold-eth/contract";
export const ContractVariables = ({
refreshDisplayVariables,
deployedContractData,
}: {
refreshDisplayVariables: boolean;
deployedContractData: Contract<ContractName>;
}) => {
if (!deployedContractData) {
return null;
}
const functionsToDisplay = (
(deployedContractData.abi as Abi).filter(part => part.type === "function") as AbiFunction[]
)
.filter(fn => {
const isQueryableWithNoParams =
(fn.stateMutability === "view" || fn.stateMutability === "pure") && fn.inputs.length === 0;
return isQueryableWithNoParams;
})
.map(fn => {
return {
fn,
inheritedFrom: ((deployedContractData as GenericContract)?.inheritedFunctions as InheritedFunctions)?.[fn.name],
};
})
.sort((a, b) => (b.inheritedFrom ? b.inheritedFrom.localeCompare(a.inheritedFrom) : 1));
if (!functionsToDisplay.length) {
return <>No contract variables</>;
}
return (
<>
{functionsToDisplay.map(({ fn, inheritedFrom }) => (
<DisplayVariable
abi={deployedContractData.abi as Abi}
abiFunction={fn}
contractAddress={deployedContractData.address}
key={fn.name}
refreshDisplayVariables={refreshDisplayVariables}
inheritedFrom={inheritedFrom}
/>
))}
</>
);
};

View File

@@ -0,0 +1,49 @@
import { Abi, AbiFunction } from "abitype";
import { WriteOnlyFunctionForm } from "~~/app/debug/_components/contract";
import { Contract, ContractName, GenericContract, InheritedFunctions } from "~~/utils/scaffold-eth/contract";
export const ContractWriteMethods = ({
onChange,
deployedContractData,
}: {
onChange: () => void;
deployedContractData: Contract<ContractName>;
}) => {
if (!deployedContractData) {
return null;
}
const functionsToDisplay = (
(deployedContractData.abi as Abi).filter(part => part.type === "function") as AbiFunction[]
)
.filter(fn => {
const isWriteableFunction = fn.stateMutability !== "view" && fn.stateMutability !== "pure";
return isWriteableFunction;
})
.map(fn => {
return {
fn,
inheritedFrom: ((deployedContractData as GenericContract)?.inheritedFunctions as InheritedFunctions)?.[fn.name],
};
})
.sort((a, b) => (b.inheritedFrom ? b.inheritedFrom.localeCompare(a.inheritedFrom) : 1));
if (!functionsToDisplay.length) {
return <>No write methods</>;
}
return (
<>
{functionsToDisplay.map(({ fn, inheritedFrom }, idx) => (
<WriteOnlyFunctionForm
abi={deployedContractData.abi as Abi}
key={`${fn.name}-${idx}}`}
abiFunction={fn}
onChange={onChange}
contractAddress={deployedContractData.address}
inheritedFrom={inheritedFrom}
/>
))}
</>
);
};

View File

@@ -0,0 +1,85 @@
"use client";
import { useEffect } from "react";
import { InheritanceTooltip } from "./InheritanceTooltip";
import { displayTxResult } from "./utilsDisplay";
import { Abi, AbiFunction } from "abitype";
import { Address } from "viem";
import { useReadContract } from "wagmi";
import { ArrowPathIcon } from "@heroicons/react/24/outline";
import { useAnimationConfig } from "~~/hooks/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { getParsedError, notification } from "~~/utils/scaffold-eth";
type DisplayVariableProps = {
contractAddress: Address;
abiFunction: AbiFunction;
refreshDisplayVariables: boolean;
inheritedFrom?: string;
abi: Abi;
};
export const DisplayVariable = ({
contractAddress,
abiFunction,
refreshDisplayVariables,
abi,
inheritedFrom,
}: DisplayVariableProps) => {
const { targetNetwork } = useTargetNetwork();
const {
data: result,
isFetching,
refetch,
error,
} = useReadContract({
address: contractAddress,
functionName: abiFunction.name,
abi: abi,
chainId: targetNetwork.id,
query: {
retry: false,
},
});
const { showAnimation } = useAnimationConfig(result);
useEffect(() => {
refetch();
}, [refetch, refreshDisplayVariables]);
useEffect(() => {
if (error) {
const parsedError = getParsedError(error);
notification.error(parsedError);
}
}, [error]);
return (
<div className="space-y-1 pb-2">
<div className="flex items-center">
<h3 className="font-medium text-lg mb-0 break-all">{abiFunction.name}</h3>
<button className="btn btn-ghost btn-xs" onClick={async () => await refetch()}>
{isFetching ? (
<span className="loading loading-spinner loading-xs"></span>
) : (
<ArrowPathIcon className="h-3 w-3 cursor-pointer" aria-hidden="true" />
)}
</button>
<InheritanceTooltip inheritedFrom={inheritedFrom} />
</div>
<div className="text-base-content/80 flex flex-col items-start">
<div>
<div
className={`break-all block transition bg-transparent ${
showAnimation ? "bg-warning rounded-xs animate-pulse-fast" : ""
}`}
>
{displayTxResult(result)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,14 @@
import { InformationCircleIcon } from "@heroicons/react/20/solid";
export const InheritanceTooltip = ({ inheritedFrom }: { inheritedFrom?: string }) => (
<>
{inheritedFrom && (
<span
className="tooltip tooltip-top tooltip-accent px-2 md:break-words"
data-tip={`Inherited from: ${inheritedFrom}`}
>
<InformationCircleIcon className="h-4 w-4" aria-hidden="true" />
</span>
)}
</>
);

View File

@@ -0,0 +1,102 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { InheritanceTooltip } from "./InheritanceTooltip";
import { Abi, AbiFunction } from "abitype";
import { Address } from "viem";
import { useReadContract } from "wagmi";
import {
ContractInput,
displayTxResult,
getFunctionInputKey,
getInitialFormState,
getParsedContractFunctionArgs,
transformAbiFunction,
} from "~~/app/debug/_components/contract";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { getParsedError, notification } from "~~/utils/scaffold-eth";
type ReadOnlyFunctionFormProps = {
contractAddress: Address;
abiFunction: AbiFunction;
inheritedFrom?: string;
abi: Abi;
};
export const ReadOnlyFunctionForm = ({
contractAddress,
abiFunction,
inheritedFrom,
abi,
}: ReadOnlyFunctionFormProps) => {
const [form, setForm] = useState<Record<string, any>>(() => getInitialFormState(abiFunction));
const [result, setResult] = useState<unknown>();
const { targetNetwork } = useTargetNetwork();
const { isFetching, refetch, error } = useReadContract({
address: contractAddress,
functionName: abiFunction.name,
abi: abi,
args: getParsedContractFunctionArgs(form),
chainId: targetNetwork.id,
query: {
enabled: false,
retry: false,
},
});
useEffect(() => {
if (error) {
const parsedError = getParsedError(error);
notification.error(parsedError);
}
}, [error]);
const transformedFunction = useMemo(() => transformAbiFunction(abiFunction), [abiFunction]);
const inputElements = transformedFunction.inputs.map((input, inputIndex) => {
const key = getFunctionInputKey(abiFunction.name, input, inputIndex);
return (
<ContractInput
key={key}
setForm={updatedFormValue => {
setResult(undefined);
setForm(updatedFormValue);
}}
form={form}
stateObjectKey={key}
paramType={input}
/>
);
});
return (
<div className="flex flex-col gap-3 py-5 first:pt-0 last:pb-1">
<p className="font-medium my-0 break-words">
{abiFunction.name}
<InheritanceTooltip inheritedFrom={inheritedFrom} />
</p>
{inputElements}
<div className="flex flex-col md:flex-row justify-between gap-2 flex-wrap">
<div className="grow w-full md:max-w-[80%]">
{result !== null && result !== undefined && (
<div className="bg-secondary rounded-3xl text-sm px-4 py-1.5 break-words overflow-auto">
<p className="font-bold m-0 mb-1">Result:</p>
<pre className="whitespace-pre-wrap break-words">{displayTxResult(result, "sm")}</pre>
</div>
)}
</div>
<button
className="btn btn-secondary btn-sm self-end md:self-start"
onClick={async () => {
const { data } = await refetch();
setResult(data);
}}
disabled={isFetching}
>
{isFetching && <span className="loading loading-spinner loading-xs"></span>}
Read 📡
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,44 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { ContractInput } from "./ContractInput";
import { getFunctionInputKey, getInitialTupleFormState } from "./utilsContract";
import { replacer } from "~~/utils/scaffold-eth/common";
import { AbiParameterTuple } from "~~/utils/scaffold-eth/contract";
type TupleProps = {
abiTupleParameter: AbiParameterTuple;
setParentForm: Dispatch<SetStateAction<Record<string, any>>>;
parentStateObjectKey: string;
parentForm: Record<string, any> | undefined;
};
export const Tuple = ({ abiTupleParameter, setParentForm, parentStateObjectKey }: TupleProps) => {
const [form, setForm] = useState<Record<string, any>>(() => getInitialTupleFormState(abiTupleParameter));
useEffect(() => {
const values = Object.values(form);
const argsStruct: Record<string, any> = {};
abiTupleParameter.components.forEach((component, componentIndex) => {
argsStruct[component.name || `input_${componentIndex}_`] = values[componentIndex];
});
setParentForm(parentForm => ({ ...parentForm, [parentStateObjectKey]: JSON.stringify(argsStruct, replacer) }));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(form, replacer)]);
return (
<div>
<div tabIndex={0} className="collapse collapse-arrow bg-base-200 pl-4 py-1.5 border-2 border-secondary">
<input type="checkbox" className="min-h-fit! peer" />
<div className="collapse-title after:top-3.5! p-0 min-h-fit! peer-checked:mb-2 text-primary-content/50">
<p className="m-0 p-0 text-[1rem]">{abiTupleParameter.internalType}</p>
</div>
<div className="ml-3 flex-col space-y-4 border-secondary/80 border-l-2 pl-4 collapse-content">
{abiTupleParameter?.components?.map((param, index) => {
const key = getFunctionInputKey(abiTupleParameter.name || "tuple", param, index);
return <ContractInput setForm={setForm} form={form} key={key} stateObjectKey={key} paramType={param} />;
})}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,142 @@
import { Dispatch, SetStateAction, useEffect, useState } from "react";
import { ContractInput } from "./ContractInput";
import { getFunctionInputKey, getInitialTupleArrayFormState } from "./utilsContract";
import { replacer } from "~~/utils/scaffold-eth/common";
import { AbiParameterTuple } from "~~/utils/scaffold-eth/contract";
type TupleArrayProps = {
abiTupleParameter: AbiParameterTuple & { isVirtual?: true };
setParentForm: Dispatch<SetStateAction<Record<string, any>>>;
parentStateObjectKey: string;
parentForm: Record<string, any> | undefined;
};
export const TupleArray = ({ abiTupleParameter, setParentForm, parentStateObjectKey }: TupleArrayProps) => {
const [form, setForm] = useState<Record<string, any>>(() => getInitialTupleArrayFormState(abiTupleParameter));
const [additionalInputs, setAdditionalInputs] = useState<Array<typeof abiTupleParameter.components>>([
abiTupleParameter.components,
]);
const depth = (abiTupleParameter.type.match(/\[\]/g) || []).length;
useEffect(() => {
// Extract and group fields based on index prefix
const groupedFields = Object.keys(form).reduce(
(acc, key) => {
const [indexPrefix, ...restArray] = key.split("_");
const componentName = restArray.join("_");
if (!acc[indexPrefix]) {
acc[indexPrefix] = {};
}
acc[indexPrefix][componentName] = form[key];
return acc;
},
{} as Record<string, Record<string, any>>,
);
let argsArray: Array<Record<string, any>> = [];
Object.keys(groupedFields).forEach(key => {
const currentKeyValues = Object.values(groupedFields[key]);
const argsStruct: Record<string, any> = {};
abiTupleParameter.components.forEach((component, componentIndex) => {
argsStruct[component.name || `input_${componentIndex}_`] = currentKeyValues[componentIndex];
});
argsArray.push(argsStruct);
});
if (depth > 1) {
argsArray = argsArray.map(args => {
return args[abiTupleParameter.components[0].name || "tuple"];
});
}
setParentForm(parentForm => {
return { ...parentForm, [parentStateObjectKey]: JSON.stringify(argsArray, replacer) };
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(form, replacer)]);
const addInput = () => {
setAdditionalInputs(previousValue => {
const newAdditionalInputs = [...previousValue, abiTupleParameter.components];
// Add the new inputs to the form
setForm(form => {
const newForm = { ...form };
abiTupleParameter.components.forEach((component, componentIndex) => {
const key = getFunctionInputKey(
`${newAdditionalInputs.length - 1}_${abiTupleParameter.name || "tuple"}`,
component,
componentIndex,
);
newForm[key] = "";
});
return newForm;
});
return newAdditionalInputs;
});
};
const removeInput = () => {
// Remove the last inputs from the form
setForm(form => {
const newForm = { ...form };
abiTupleParameter.components.forEach((component, componentIndex) => {
const key = getFunctionInputKey(
`${additionalInputs.length - 1}_${abiTupleParameter.name || "tuple"}`,
component,
componentIndex,
);
delete newForm[key];
});
return newForm;
});
setAdditionalInputs(inputs => inputs.slice(0, -1));
};
return (
<div>
<div className="collapse collapse-arrow bg-base-200 pl-4 py-1.5 border-2 border-secondary">
<input type="checkbox" className="min-h-fit! peer" />
<div className="collapse-title after:top-3.5! p-0 min-h-fit! peer-checked:mb-1 text-primary-content/50">
<p className="m-0 text-[1rem]">{abiTupleParameter.internalType}</p>
</div>
<div className="ml-3 flex-col space-y-2 border-secondary/70 border-l-2 pl-4 collapse-content">
{additionalInputs.map((additionalInput, additionalIndex) => (
<div key={additionalIndex} className="space-y-1">
<span className="badge bg-base-300 badge-sm">
{depth > 1 ? `${additionalIndex}` : `tuple[${additionalIndex}]`}
</span>
<div className="space-y-4">
{additionalInput.map((param, index) => {
const key = getFunctionInputKey(
`${additionalIndex}_${abiTupleParameter.name || "tuple"}`,
param,
index,
);
return (
<ContractInput setForm={setForm} form={form} key={key} stateObjectKey={key} paramType={param} />
);
})}
</div>
</div>
))}
<div className="flex space-x-2">
<button className="btn btn-sm btn-secondary" onClick={addInput}>
+
</button>
{additionalInputs.length > 0 && (
<button className="btn btn-sm btn-secondary" onClick={removeInput}>
-
</button>
)}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,42 @@
import { TransactionReceipt } from "viem";
import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
import { ObjectFieldDisplay } from "~~/app/debug/_components/contract";
import { useCopyToClipboard } from "~~/hooks/scaffold-eth/useCopyToClipboard";
import { replacer } from "~~/utils/scaffold-eth/common";
export const TxReceipt = ({ txResult }: { txResult: TransactionReceipt }) => {
const { copyToClipboard: copyTxResultToClipboard, isCopiedToClipboard: isTxResultCopiedToClipboard } =
useCopyToClipboard();
return (
<div className="flex text-sm rounded-3xl peer-checked:rounded-b-none min-h-0 bg-secondary py-0">
<div className="mt-1 pl-2">
{isTxResultCopiedToClipboard ? (
<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={() => copyTxResultToClipboard(JSON.stringify(txResult, replacer, 2))}
/>
)}
</div>
<div tabIndex={0} className="flex-wrap collapse collapse-arrow">
<input type="checkbox" className="min-h-0! peer" />
<div className="collapse-title text-sm min-h-0! py-1.5 pl-1 after:top-4!">
<strong>Transaction Receipt</strong>
</div>
<div className="collapse-content overflow-auto bg-secondary rounded-t-none rounded-3xl pl-0!">
<pre className="text-xs">
{Object.entries(txResult).map(([k, v]) => (
<ObjectFieldDisplay name={k} value={v} size="xs" leftPad={false} key={k} />
))}
</pre>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,148 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { InheritanceTooltip } from "./InheritanceTooltip";
import { Abi, AbiFunction } from "abitype";
import { Address, TransactionReceipt } from "viem";
import { useAccount, useConfig, useWaitForTransactionReceipt, useWriteContract } from "wagmi";
import {
ContractInput,
TxReceipt,
getFunctionInputKey,
getInitialFormState,
getParsedContractFunctionArgs,
transformAbiFunction,
} from "~~/app/debug/_components/contract";
import { IntegerInput } from "~~/components/scaffold-eth";
import { useTransactor } from "~~/hooks/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { AllowedChainIds } from "~~/utils/scaffold-eth";
import { simulateContractWriteAndNotifyError } from "~~/utils/scaffold-eth/contract";
type WriteOnlyFunctionFormProps = {
abi: Abi;
abiFunction: AbiFunction;
onChange: () => void;
contractAddress: Address;
inheritedFrom?: string;
};
export const WriteOnlyFunctionForm = ({
abi,
abiFunction,
onChange,
contractAddress,
inheritedFrom,
}: WriteOnlyFunctionFormProps) => {
const [form, setForm] = useState<Record<string, any>>(() => getInitialFormState(abiFunction));
const [txValue, setTxValue] = useState<string>("");
const { chain } = useAccount();
const writeTxn = useTransactor();
const { targetNetwork } = useTargetNetwork();
const writeDisabled = !chain || chain?.id !== targetNetwork.id;
const { data: result, isPending, writeContractAsync } = useWriteContract();
const wagmiConfig = useConfig();
const handleWrite = async () => {
if (writeContractAsync) {
try {
const writeContractObj = {
address: contractAddress,
functionName: abiFunction.name,
abi: abi,
args: getParsedContractFunctionArgs(form),
value: BigInt(txValue),
};
await simulateContractWriteAndNotifyError({
wagmiConfig,
writeContractParams: writeContractObj,
chainId: targetNetwork.id as AllowedChainIds,
});
const makeWriteWithParams = () => writeContractAsync(writeContractObj);
await writeTxn(makeWriteWithParams);
onChange();
} catch (e: any) {
console.error("⚡️ ~ file: WriteOnlyFunctionForm.tsx:handleWrite ~ error", e);
}
}
};
const [displayedTxResult, setDisplayedTxResult] = useState<TransactionReceipt>();
const { data: txResult } = useWaitForTransactionReceipt({
hash: result,
});
useEffect(() => {
setDisplayedTxResult(txResult);
}, [txResult]);
const transformedFunction = useMemo(() => transformAbiFunction(abiFunction), [abiFunction]);
const inputs = transformedFunction.inputs.map((input, inputIndex) => {
const key = getFunctionInputKey(abiFunction.name, input, inputIndex);
return (
<ContractInput
key={key}
setForm={updatedFormValue => {
setDisplayedTxResult(undefined);
setForm(updatedFormValue);
}}
form={form}
stateObjectKey={key}
paramType={input}
/>
);
});
const zeroInputs = inputs.length === 0 && abiFunction.stateMutability !== "payable";
return (
<div className="py-5 space-y-3 first:pt-0 last:pb-1">
<div className={`flex gap-3 ${zeroInputs ? "flex-row justify-between items-center" : "flex-col"}`}>
<p className="font-medium my-0 break-words">
{abiFunction.name}
<InheritanceTooltip inheritedFrom={inheritedFrom} />
</p>
{inputs}
{abiFunction.stateMutability === "payable" ? (
<div className="flex flex-col gap-1.5 w-full">
<div className="flex items-center ml-2">
<span className="text-xs font-medium mr-2 leading-none">payable value</span>
<span className="block text-xs font-extralight leading-none">wei</span>
</div>
<IntegerInput
value={txValue}
onChange={updatedTxValue => {
setDisplayedTxResult(undefined);
setTxValue(updatedTxValue);
}}
placeholder="value (wei)"
/>
</div>
) : null}
<div className="flex justify-between gap-2">
{!zeroInputs && (
<div className="grow basis-0">{displayedTxResult ? <TxReceipt txResult={displayedTxResult} /> : null}</div>
)}
<div
className={`flex ${
writeDisabled &&
"tooltip tooltip-bottom tooltip-secondary before:content-[attr(data-tip)] before:-translate-x-1/3 before:left-auto before:transform-none"
}`}
data-tip={`${writeDisabled && "Wallet not connected or in the wrong network"}`}
>
<button className="btn btn-secondary btn-sm" disabled={writeDisabled || isPending} onClick={handleWrite}>
{isPending && <span className="loading loading-spinner loading-xs"></span>}
Send 💸
</button>
</div>
</div>
</div>
{zeroInputs && txResult ? (
<div className="grow basis-0">
<TxReceipt txResult={txResult} />
</div>
) : null}
</div>
);
};

View File

@@ -0,0 +1,8 @@
export * from "./ContractInput";
export * from "./ContractUI";
export * from "./DisplayVariable";
export * from "./ReadOnlyFunctionForm";
export * from "./TxReceipt";
export * from "./utilsContract";
export * from "./utilsDisplay";
export * from "./WriteOnlyFunctionForm";

View File

@@ -0,0 +1,166 @@
import { AbiFunction, AbiParameter } from "abitype";
import { AbiParameterTuple } from "~~/utils/scaffold-eth/contract";
/**
* Generates a key based on function metadata
*/
const getFunctionInputKey = (functionName: string, input: AbiParameter, inputIndex: number): string => {
const name = input?.name || `input_${inputIndex}_`;
return functionName + "_" + name + "_" + input.internalType + "_" + input.type;
};
const isJsonString = (str: string) => {
try {
JSON.parse(str);
return true;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
return false;
}
};
const isBigInt = (str: string) => {
if (str.trim().length === 0 || str.startsWith("0")) return false;
try {
BigInt(str);
return true;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
return false;
}
};
// Recursive function to deeply parse JSON strings, correctly handling nested arrays and encoded JSON strings
const deepParseValues = (value: any): any => {
if (typeof value === "string") {
// first try with bigInt because we losse precision with JSON.parse
if (isBigInt(value)) {
return BigInt(value);
}
if (isJsonString(value)) {
const parsed = JSON.parse(value);
return deepParseValues(parsed);
}
// It's a string but not a JSON string, return as is
return value;
} else if (Array.isArray(value)) {
// If it's an array, recursively parse each element
return value.map(element => deepParseValues(element));
} else if (typeof value === "object" && value !== null) {
// If it's an object, recursively parse each value
return Object.entries(value).reduce((acc: any, [key, val]) => {
acc[key] = deepParseValues(val);
return acc;
}, {});
}
// Handle boolean values represented as strings
if (value === "true" || value === "1" || value === "0x1" || value === "0x01" || value === "0x0001") {
return true;
} else if (value === "false" || value === "0" || value === "0x0" || value === "0x00" || value === "0x0000") {
return false;
}
return value;
};
/**
* parses form input with array support
*/
const getParsedContractFunctionArgs = (form: Record<string, any>) => {
return Object.keys(form).map(key => {
const valueOfArg = form[key];
// Attempt to deeply parse JSON strings
return deepParseValues(valueOfArg);
});
};
const getInitialFormState = (abiFunction: AbiFunction) => {
const initialForm: Record<string, any> = {};
if (!abiFunction.inputs) return initialForm;
abiFunction.inputs.forEach((input, inputIndex) => {
const key = getFunctionInputKey(abiFunction.name, input, inputIndex);
initialForm[key] = "";
});
return initialForm;
};
const getInitialTupleFormState = (abiTupleParameter: AbiParameterTuple) => {
const initialForm: Record<string, any> = {};
if (abiTupleParameter.components.length === 0) return initialForm;
abiTupleParameter.components.forEach((component, componentIndex) => {
const key = getFunctionInputKey(abiTupleParameter.name || "tuple", component, componentIndex);
initialForm[key] = "";
});
return initialForm;
};
const getInitialTupleArrayFormState = (abiTupleParameter: AbiParameterTuple) => {
const initialForm: Record<string, any> = {};
if (abiTupleParameter.components.length === 0) return initialForm;
abiTupleParameter.components.forEach((component, componentIndex) => {
const key = getFunctionInputKey("0_" + abiTupleParameter.name || "tuple", component, componentIndex);
initialForm[key] = "";
});
return initialForm;
};
const adjustInput = (input: AbiParameterTuple): AbiParameter => {
if (input.type.startsWith("tuple[")) {
const depth = (input.type.match(/\[\]/g) || []).length;
return {
...input,
components: transformComponents(input.components, depth, {
internalType: input.internalType || "struct",
name: input.name,
}),
};
} else if (input.components) {
return {
...input,
components: input.components.map(value => adjustInput(value as AbiParameterTuple)),
};
}
return input;
};
const transformComponents = (
components: readonly AbiParameter[],
depth: number,
parentComponentData: { internalType?: string; name?: string },
): AbiParameter[] => {
// Base case: if depth is 1 or no components, return the original components
if (depth === 1 || !components) {
return [...components];
}
// Recursive case: wrap components in an additional tuple layer
const wrappedComponents: AbiParameter = {
internalType: `${parentComponentData.internalType || "struct"}`.replace(/\[\]/g, "") + "[]".repeat(depth - 1),
name: `${parentComponentData.name || "tuple"}`,
type: `tuple${"[]".repeat(depth - 1)}`,
components: transformComponents(components, depth - 1, parentComponentData),
};
return [wrappedComponents];
};
const transformAbiFunction = (abiFunction: AbiFunction): AbiFunction => {
return {
...abiFunction,
inputs: abiFunction.inputs.map(value => adjustInput(value as AbiParameterTuple)),
};
};
export {
getFunctionInputKey,
getInitialFormState,
getParsedContractFunctionArgs,
getInitialTupleFormState,
getInitialTupleArrayFormState,
transformAbiFunction,
};

View File

@@ -0,0 +1,114 @@
import { ReactElement, useState } from "react";
import { TransactionBase, TransactionReceipt, formatEther, isAddress, isHex } from "viem";
import { ArrowsRightLeftIcon } from "@heroicons/react/24/solid";
import { Address } from "~~/components/scaffold-eth";
import { replacer } from "~~/utils/scaffold-eth/common";
type DisplayContent =
| string
| number
| bigint
| Record<string, any>
| TransactionBase
| TransactionReceipt
| undefined
| unknown;
type ResultFontSize = "sm" | "base" | "xs" | "lg" | "xl" | "2xl" | "3xl";
export const displayTxResult = (
displayContent: DisplayContent | DisplayContent[],
fontSize: ResultFontSize = "base",
): string | ReactElement | number => {
if (displayContent == null) {
return "";
}
if (typeof displayContent === "bigint") {
return <NumberDisplay value={displayContent} />;
}
if (typeof displayContent === "string") {
if (isAddress(displayContent)) {
return <Address address={displayContent} size={fontSize} onlyEnsOrAddress />;
}
if (isHex(displayContent)) {
return displayContent; // don't add quotes
}
}
if (Array.isArray(displayContent)) {
return <ArrayDisplay values={displayContent} size={fontSize} />;
}
if (typeof displayContent === "object") {
return <StructDisplay struct={displayContent} size={fontSize} />;
}
return JSON.stringify(displayContent, replacer, 2);
};
const NumberDisplay = ({ value }: { value: bigint }) => {
const [isEther, setIsEther] = useState(false);
const asNumber = Number(value);
if (asNumber <= Number.MAX_SAFE_INTEGER && asNumber >= Number.MIN_SAFE_INTEGER) {
return String(value);
}
return (
<div className="flex items-baseline">
{isEther ? "Ξ" + formatEther(value) : String(value)}
<span
className="tooltip tooltip-secondary font-sans ml-2"
data-tip={isEther ? "Multiply by 1e18" : "Divide by 1e18"}
>
<button className="btn btn-ghost btn-circle btn-xs" onClick={() => setIsEther(!isEther)}>
<ArrowsRightLeftIcon className="h-3 w-3 opacity-65" />
</button>
</span>
</div>
);
};
export const ObjectFieldDisplay = ({
name,
value,
size,
leftPad = true,
}: {
name: string;
value: DisplayContent;
size: ResultFontSize;
leftPad?: boolean;
}) => {
return (
<div className={`flex flex-row items-baseline ${leftPad ? "ml-4" : ""}`}>
<span className="text-base-content/60 mr-2">{name}:</span>
<span className="text-base-content">{displayTxResult(value, size)}</span>
</div>
);
};
const ArrayDisplay = ({ values, size }: { values: DisplayContent[]; size: ResultFontSize }) => {
return (
<div className="flex flex-col gap-y-1">
{values.length ? "array" : "[]"}
{values.map((v, i) => (
<ObjectFieldDisplay key={i} name={`[${i}]`} value={v} size={size} />
))}
</div>
);
};
const StructDisplay = ({ struct, size }: { struct: Record<string, any>; size: ResultFontSize }) => {
return (
<div className="flex flex-col gap-y-1">
struct
{Object.entries(struct).map(([k, v]) => (
<ObjectFieldDisplay key={k} name={k} value={v} size={size} />
))}
</div>
);
};

View 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;

View File

@@ -0,0 +1,82 @@
"use client";
import { lazy, useEffect, useState } from "react";
import type { NextPage } from "next";
import { notification } from "~~/utils/scaffold-eth";
import { getMetadataFromIPFS } from "~~/utils/tokenization/ipfs-fetch";
const LazyReactJson = lazy(() => import("react-json-view"));
const IpfsDownload: NextPage = () => {
const [yourJSON, setYourJSON] = useState({});
const [ipfsPath, setIpfsPath] = useState("");
const [loading, setLoading] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const handleIpfsDownload = async () => {
setLoading(true);
const notificationId = notification.loading("Getting data from IPFS");
try {
const metaData = await getMetadataFromIPFS(ipfsPath);
notification.remove(notificationId);
notification.success("Downloaded from IPFS");
setYourJSON(metaData);
} catch (error) {
notification.remove(notificationId);
notification.error("Error downloading from IPFS");
console.log(error);
} finally {
setLoading(false);
}
};
return (
<>
<div className="flex items-center flex-col flex-grow pt-10">
<h1 className="text-center mb-4">
<span className="block text-4xl font-bold">Download from IPFS</span>
</h1>
<div className={`flex border-2 border-accent/95 bg-base-200 rounded-full text-accent w-96`}>
<input
className="input input-ghost focus:outline-none focus:bg-transparent focus:text-secondary-content h-[2.2rem] min-h-[2.2rem] px-4 border w-full font-medium placeholder:text-accent/50 text-secondary-content/75"
placeholder="IPFS CID"
value={ipfsPath}
onChange={e => setIpfsPath(e.target.value)}
autoComplete="off"
/>
</div>
<button
className={`btn btn-secondary my-6 ${loading ? "loading" : ""}`}
disabled={loading}
onClick={handleIpfsDownload}
>
Download from IPFS
</button>
{mounted && (
<LazyReactJson
style={{ padding: "1rem", borderRadius: "0.75rem" }}
src={yourJSON}
theme="solarized"
enableClipboard={false}
onEdit={edit => {
setYourJSON(edit.updated_src);
}}
onAdd={add => {
setYourJSON(add.updated_src);
}}
onDelete={del => {
setYourJSON(del.updated_src);
}}
/>
)}
</div>
</>
);
};
export default IpfsDownload;

View File

@@ -0,0 +1,81 @@
"use client";
import { lazy, useEffect, useState } from "react";
import type { NextPage } from "next";
import { notification } from "~~/utils/scaffold-eth";
import { addToIPFS } from "~~/utils/tokenization/ipfs-fetch";
import nftsMetadata from "~~/utils/tokenization/nftsMetadata";
const LazyReactJson = lazy(() => import("react-json-view"));
const IpfsUpload: NextPage = () => {
const [yourJSON, setYourJSON] = useState<object>(nftsMetadata[0]);
const [loading, setLoading] = useState(false);
const [uploadedIpfsPath, setUploadedIpfsPath] = useState("");
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const handleIpfsUpload = async () => {
setLoading(true);
const notificationId = notification.loading("Uploading to IPFS...");
try {
const uploadedItem = await addToIPFS(yourJSON);
notification.remove(notificationId);
notification.success("Uploaded to IPFS");
setUploadedIpfsPath(uploadedItem.path);
} catch (error) {
notification.remove(notificationId);
notification.error("Error uploading to IPFS");
console.log(error);
} finally {
setLoading(false);
}
};
return (
<>
<div className="flex items-center flex-col flex-grow pt-10">
<h1 className="text-center mb-4">
<span className="block text-4xl font-bold">Upload to IPFS</span>
</h1>
{mounted && (
<LazyReactJson
style={{ padding: "1rem", borderRadius: "0.75rem" }}
src={yourJSON}
theme="solarized"
enableClipboard={false}
onEdit={edit => {
setYourJSON(edit.updated_src);
}}
onAdd={add => {
setYourJSON(add.updated_src);
}}
onDelete={del => {
setYourJSON(del.updated_src);
}}
/>
)}
<button
className={`btn btn-secondary mt-4 ${loading ? "loading" : ""}`}
disabled={loading}
onClick={handleIpfsUpload}
>
Upload to IPFS
</button>
{uploadedIpfsPath && (
<div className="mt-4">
<a href={`https://ipfs.io/ipfs/${uploadedIpfsPath}`} target="_blank" rel="noreferrer">
{`https://ipfs.io/ipfs/${uploadedIpfsPath}`}
</a>
</div>
)}
</div>
</>
);
};
export default IpfsUpload;

View File

@@ -0,0 +1,30 @@
import { Space_Grotesk } from "next/font/google";
import "@rainbow-me/rainbowkit/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: "Tokenization | SpeedRunEthereum",
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;

View File

@@ -0,0 +1,97 @@
"use client";
import { useEffect, useState } from "react";
import { NFTCard } from "./NFTCard";
import { useAccount } from "wagmi";
import { useScaffoldContract, useScaffoldReadContract } from "~~/hooks/scaffold-eth";
import { notification } from "~~/utils/scaffold-eth";
import { getMetadataFromIPFS } from "~~/utils/tokenization/ipfs-fetch";
import { NFTMetaData } from "~~/utils/tokenization/nftsMetadata";
export interface Collectible extends Partial<NFTMetaData> {
id: number;
uri: string;
owner: string;
}
export const MyHoldings = () => {
const { address: connectedAddress } = useAccount();
const [myAllCollectibles, setMyAllCollectibles] = useState<Collectible[]>([]);
const [allCollectiblesLoading, setAllCollectiblesLoading] = useState(false);
const { data: yourCollectibleContract } = useScaffoldContract({
contractName: "YourCollectible",
});
const { data: myTotalBalance } = useScaffoldReadContract({
contractName: "YourCollectible",
functionName: "balanceOf",
args: [connectedAddress],
watch: true,
});
useEffect(() => {
const updateMyCollectibles = async (): Promise<void> => {
if (myTotalBalance === undefined || yourCollectibleContract === undefined || connectedAddress === undefined)
return;
setAllCollectiblesLoading(true);
const collectibleUpdate: Collectible[] = [];
const totalBalance = parseInt(myTotalBalance.toString());
for (let tokenIndex = 0; tokenIndex < totalBalance; tokenIndex++) {
try {
const tokenId = await yourCollectibleContract.read.tokenOfOwnerByIndex([
connectedAddress,
BigInt(tokenIndex),
]);
const tokenURI = await yourCollectibleContract.read.tokenURI([tokenId]);
const ipfsHash = tokenURI.replace("https://ipfs.io/ipfs/", "");
const nftMetadata: NFTMetaData = await getMetadataFromIPFS(ipfsHash);
collectibleUpdate.push({
id: parseInt(tokenId.toString()),
uri: tokenURI,
owner: connectedAddress,
...nftMetadata,
});
} catch (e) {
notification.error("Error fetching all collectibles");
setAllCollectiblesLoading(false);
console.log(e);
}
}
collectibleUpdate.sort((a, b) => a.id - b.id);
setMyAllCollectibles(collectibleUpdate);
setAllCollectiblesLoading(false);
};
updateMyCollectibles();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectedAddress, myTotalBalance]);
if (allCollectiblesLoading)
return (
<div className="flex justify-center items-center mt-10">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
return (
<>
{myAllCollectibles.length === 0 ? (
<div className="flex justify-center items-center mt-10">
<div className="text-2xl text-primary-content">No NFTs found</div>
</div>
) : (
<div className="flex flex-wrap gap-4 my-8 px-5 justify-center">
{myAllCollectibles.map(item => (
<NFTCard nft={item} key={item.id} />
))}
</div>
)}
</>
);
};

View File

@@ -0,0 +1,66 @@
import { useState } from "react";
import { Collectible } from "./MyHoldings";
import { Address, AddressInput } from "~~/components/scaffold-eth";
import { useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
export const NFTCard = ({ nft }: { nft: Collectible }) => {
const [transferToAddress, setTransferToAddress] = useState("");
const { writeContractAsync } = useScaffoldWriteContract({ contractName: "YourCollectible" });
return (
<div className="card card-compact bg-base-100 shadow-lg w-[300px] shadow-secondary">
<figure className="relative">
{/* eslint-disable-next-line */}
<img src={nft.image} alt="NFT Image" className="h-60 min-w-full" />
<figcaption className="glass absolute bottom-4 left-4 p-4 rounded-xl">
<span className="text-white "># {nft.id}</span>
</figcaption>
</figure>
<div className="card-body space-y-3">
<div className="flex items-center justify-center">
<p className="text-xl p-0 m-0 font-semibold">{nft.name}</p>
<div className="flex flex-wrap space-x-2 mt-1">
{nft.attributes?.map((attr, index) => (
<span key={index} className="badge badge-primary px-1.5">
{attr.value}
</span>
))}
</div>
</div>
<div className="flex flex-col justify-center mt-1">
<p className="my-0 text-lg">{nft.description}</p>
</div>
<div className="flex space-x-3 mt-1 items-center">
<span className="text-lg font-semibold">Owner : </span>
<Address address={nft.owner} />
</div>
<div className="flex flex-col my-2 space-y-1">
<span className="text-lg font-semibold mb-1">Transfer To: </span>
<AddressInput
value={transferToAddress}
placeholder="receiver address"
onChange={newValue => setTransferToAddress(newValue)}
/>
</div>
<div className="card-actions justify-end">
<button
className="btn btn-secondary btn-md px-8 tracking-wide"
onClick={() => {
try {
writeContractAsync({
functionName: "transferFrom",
args: [nft.owner, transferToAddress, BigInt(nft.id.toString())],
});
} catch (err) {
console.error("Error calling transferFrom function", err);
}
}}
>
Send
</button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export * from "./MyHoldings";

View File

@@ -0,0 +1,70 @@
"use client";
import { MyHoldings } from "./_components";
import type { NextPage } from "next";
import { useAccount } from "wagmi";
import { RainbowKitCustomConnectButton } from "~~/components/scaffold-eth";
import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
import { notification } from "~~/utils/scaffold-eth";
import { addToIPFS } from "~~/utils/tokenization/ipfs-fetch";
import nftsMetadata from "~~/utils/tokenization/nftsMetadata";
const MyNFTs: NextPage = () => {
const { address: connectedAddress, isConnected, isConnecting } = useAccount();
const { writeContractAsync } = useScaffoldWriteContract({ contractName: "YourCollectible" });
const { data: tokenIdCounter } = useScaffoldReadContract({
contractName: "YourCollectible",
functionName: "tokenIdCounter",
watch: true,
});
const handleMintItem = async () => {
// circle back to the zero item if we've reached the end of the array
if (tokenIdCounter === undefined) return;
const tokenIdCounterNumber = Number(tokenIdCounter);
const currentTokenMetaData = nftsMetadata[tokenIdCounterNumber % nftsMetadata.length];
const notificationId = notification.loading("Uploading to IPFS");
try {
const uploadedItem = await addToIPFS(currentTokenMetaData);
// First remove previous loading notification and then show success notification
notification.remove(notificationId);
notification.success("Metadata uploaded to IPFS");
await writeContractAsync({
functionName: "mintItem",
args: [connectedAddress, uploadedItem.path],
});
} catch (error) {
notification.remove(notificationId);
console.error(error);
}
};
return (
<>
<div className="flex items-center flex-col pt-10">
<div className="px-5">
<h1 className="text-center mb-8">
<span className="block text-4xl font-bold">My NFTs</span>
</h1>
</div>
</div>
<div className="flex justify-center">
{!isConnected || isConnecting ? (
<RainbowKitCustomConnectButton />
) : (
<button className="btn btn-secondary" onClick={handleMintItem}>
Mint NFT
</button>
)}
</div>
<MyHoldings />
</>
);
};
export default MyNFTs;

View 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&apos;re looking for doesn&apos;t exist.</p>
<Link href="/" className="btn btn-primary">
Go Home
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import type { NextPage } from "next";
import { useAccount } from "wagmi";
import { BugAntIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { Address } from "~~/components/scaffold-eth";
const Home: NextPage = () => {
const { address: connectedAddress } = useAccount();
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">(SpeedRunEthereum Challenge: Tokenization 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} />
</div>
<div className="flex items-center flex-col flex-grow mt-4">
<div className="px-5 w-[90%]">
<h1 className="text-center mb-6">
<span className="block text-4xl font-bold">Challenge: Tokenization</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">
🎫 Create a unique token to learn the basics of 🏗 Scaffold-ETH 2. You'll use 👷‍♀️
<a
href="https://hardhat.org/getting-started/"
target="_blank"
rel="noreferrer"
className="underline"
>
HardHat
</a>{" "}
to compile and deploy smart contracts. Then, you'll use a template React app full of important
Ethereum components and hooks. Finally, you'll deploy an NFT to a public network to share with
friends! 🚀
</p>
<p className="text-center text-lg">
🌟 The final deliverable is an app that lets users purchase and transfer NFTs. Deploy your contracts
to a testnet then build and upload your app to a public web server. 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;

View File

@@ -0,0 +1,67 @@
"use client";
import type { NextPage } from "next";
import { Address } from "~~/components/scaffold-eth";
import { useScaffoldEventHistory } from "~~/hooks/scaffold-eth";
const Transfers: NextPage = () => {
const { data: transferEvents, isLoading } = useScaffoldEventHistory({
contractName: "YourCollectible",
eventName: "Transfer",
});
if (isLoading)
return (
<div className="flex justify-center items-center mt-10">
<span className="loading loading-spinner loading-xl"></span>
</div>
);
return (
<>
<div className="flex items-center flex-col flex-grow pt-10">
<div className="px-5">
<h1 className="text-center mb-8">
<span className="block text-4xl font-bold">All Transfers Events</span>
</h1>
</div>
<div className="overflow-x-auto shadow-lg">
<table className="table table-zebra w-full">
<thead>
<tr className="text-base-content">
<th className="bg-primary">Token Id</th>
<th className="bg-primary">From</th>
<th className="bg-primary">To</th>
</tr>
</thead>
<tbody>
{!transferEvents || transferEvents.length === 0 ? (
<tr>
<td colSpan={3} className="text-center">
No events found
</td>
</tr>
) : (
transferEvents?.map((event, index) => {
return (
<tr key={index}>
<th className="text-center">{event.args.tokenId?.toString()}</th>
<td>
<Address address={event.args.from} />
</td>
<td>
<Address address={event.args.to} />
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
</>
);
};
export default Transfers;

View File

@@ -0,0 +1,80 @@
import React from "react";
import Link from "next/link";
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";
import { useGlobalState } from "~~/services/store/store";
/**
* Site footer
*/
export const Footer = () => {
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
const { targetNetwork } = useTargetNetwork();
const isLocalNetwork = targetNetwork.id === hardhat.id;
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>
);
};

View File

@@ -0,0 +1,124 @@
"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 { ArrowDownTrayIcon, ArrowPathIcon, ArrowUpTrayIcon, PhotoIcon } 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: "My NFTs",
href: "/myNFTs",
icon: <PhotoIcon className="h-4 w-4" />,
},
{
label: "Transfers",
href: "/transfers",
icon: <ArrowPathIcon className="h-4 w-4" />,
},
{
label: "IPFS Upload",
href: "/ipfsUpload",
icon: <ArrowUpTrayIcon className="h-4 w-4" />,
},
{
label: "IPFS Download",
href: "/ipfsDownload",
icon: <ArrowDownTrayIcon 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">Tokenization</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>
);
};

View File

@@ -0,0 +1,61 @@
"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 { useInitializeNativeCurrencyPrice } from "~~/hooks/scaffold-eth";
import { wagmiConfig } from "~~/services/web3/wagmiConfig";
const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
useInitializeNativeCurrencyPrice();
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>
);
};

View 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>
);
};

View 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>;
};

View 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>
);
};

View File

@@ -0,0 +1,187 @@
"use client";
import { AddressCopyIcon } from "./AddressCopyIcon";
import { AddressLinkWrapper } from "./AddressLinkWrapper";
import { Address as AddressType, getAddress, isAddress } from "viem";
import { normalize } from "viem/ens";
import { useEnsAvatar, useEnsName } from "wagmi";
import { BlockieAvatar } from "~~/components/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { getBlockExplorerAddressLink } from "~~/utils/scaffold-eth";
const textSizeMap = {
"3xs": "text-[10px]",
"2xs": "text-[11px]",
xs: "text-xs",
sm: "text-sm",
base: "text-base",
lg: "text-lg",
xl: "text-xl",
"2xl": "text-2xl",
"3xl": "text-3xl",
"4xl": "text-4xl",
} as const;
const blockieSizeMap = {
"3xs": 4,
"2xs": 5,
xs: 6,
sm: 7,
base: 8,
lg: 9,
xl: 10,
"2xl": 12,
"3xl": 15,
"4xl": 17,
"5xl": 19,
"6xl": 21,
"7xl": 23,
} as const;
const copyIconSizeMap = {
"3xs": "h-2.5 w-2.5",
"2xs": "h-3 w-3",
xs: "h-3.5 w-3.5",
sm: "h-4 w-4",
base: "h-[18px] w-[18px]",
lg: "h-5 w-5",
xl: "h-[22px] w-[22px]",
"2xl": "h-6 w-6",
"3xl": "h-[26px] w-[26px]",
"4xl": "h-7 w-7",
} as const;
type SizeMap = typeof textSizeMap | typeof blockieSizeMap;
const getNextSize = <T extends SizeMap>(sizeMap: T, currentSize: keyof T, step = 1): keyof T => {
const sizes = Object.keys(sizeMap) as Array<keyof T>;
const currentIndex = sizes.indexOf(currentSize);
const nextIndex = Math.min(currentIndex + step, sizes.length - 1);
return sizes[nextIndex];
};
const getPrevSize = <T extends SizeMap>(sizeMap: T, currentSize: keyof T, step = 1): keyof T => {
const sizes = Object.keys(sizeMap) as Array<keyof T>;
const currentIndex = sizes.indexOf(currentSize);
const prevIndex = Math.max(currentIndex - step, 0);
return sizes[prevIndex];
};
type AddressProps = {
address?: AddressType;
disableAddressLink?: boolean;
format?: "short" | "long";
size?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl";
onlyEnsOrAddress?: boolean;
};
export const Address = ({
address,
disableAddressLink,
format,
size = "base",
onlyEnsOrAddress = false,
}: AddressProps) => {
const checkSumAddress = address ? getAddress(address) : undefined;
const { targetNetwork } = useTargetNetwork();
const { data: ens, isLoading: isEnsNameLoading } = useEnsName({
address: checkSumAddress,
chainId: 1,
query: {
enabled: isAddress(checkSumAddress ?? ""),
},
});
const { data: ensAvatar } = useEnsAvatar({
name: ens ? normalize(ens) : undefined,
chainId: 1,
query: {
enabled: Boolean(ens),
gcTime: 30_000,
},
});
const shortAddress = checkSumAddress?.slice(0, 6) + "..." + checkSumAddress?.slice(-4);
const displayAddress = format === "long" ? checkSumAddress : shortAddress;
const displayEnsOrAddress = ens || displayAddress;
const showSkeleton = !checkSumAddress || (!onlyEnsOrAddress && (ens || isEnsNameLoading));
const addressSize = showSkeleton && !onlyEnsOrAddress ? getPrevSize(textSizeMap, size, 2) : size;
const ensSize = getNextSize(textSizeMap, addressSize);
const blockieSize = showSkeleton && !onlyEnsOrAddress ? getNextSize(blockieSizeMap, addressSize, 4) : addressSize;
if (!checkSumAddress) {
return (
<div className="flex items-center">
<div
className="shrink-0 skeleton rounded-full"
style={{
width: (blockieSizeMap[blockieSize] * 24) / blockieSizeMap["base"],
height: (blockieSizeMap[blockieSize] * 24) / blockieSizeMap["base"],
}}
></div>
<div className="flex flex-col space-y-1">
{!onlyEnsOrAddress && (
<div className={`ml-1.5 skeleton rounded-lg font-bold ${textSizeMap[ensSize]}`}>
<span className="invisible">0x1234...56789</span>
</div>
)}
<div className={`ml-1.5 skeleton rounded-lg ${textSizeMap[addressSize]}`}>
<span className="invisible">0x1234...56789</span>
</div>
</div>
</div>
);
}
if (!isAddress(checkSumAddress)) {
return <span className="text-error">Wrong address</span>;
}
const blockExplorerAddressLink = getBlockExplorerAddressLink(targetNetwork, checkSumAddress);
return (
<div className="flex items-center shrink-0">
<div className="shrink-0">
<BlockieAvatar
address={checkSumAddress}
ensImage={ensAvatar}
size={(blockieSizeMap[blockieSize] * 24) / blockieSizeMap["base"]}
/>
</div>
<div className="flex flex-col">
{showSkeleton &&
(isEnsNameLoading ? (
<div className={`ml-1.5 skeleton rounded-lg font-bold ${textSizeMap[ensSize]}`}>
<span className="invisible">{shortAddress}</span>
</div>
) : (
<span className={`ml-1.5 ${textSizeMap[ensSize]} font-bold`}>
<AddressLinkWrapper
disableAddressLink={disableAddressLink}
blockExplorerAddressLink={blockExplorerAddressLink}
>
{ens}
</AddressLinkWrapper>
</span>
))}
<div className="flex">
<span className={`ml-1.5 ${textSizeMap[addressSize]} font-normal`}>
<AddressLinkWrapper
disableAddressLink={disableAddressLink}
blockExplorerAddressLink={blockExplorerAddressLink}
>
{onlyEnsOrAddress ? displayEnsOrAddress : displayAddress}
</AddressLinkWrapper>
</span>
<AddressCopyIcon
className={`ml-1 ${copyIconSizeMap[addressSize]} cursor-pointer`}
address={checkSumAddress}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,23 @@
import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
import { useCopyToClipboard } from "~~/hooks/scaffold-eth/useCopyToClipboard";
export const AddressCopyIcon = ({ className, address }: { className?: string; address: string }) => {
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
useCopyToClipboard();
return (
<button
onClick={e => {
e.stopPropagation();
copyAddressToClipboard(address);
}}
type="button"
>
{isAddressCopiedToClipboard ? (
<CheckCircleIcon className={className} aria-hidden="true" />
) : (
<DocumentDuplicateIcon className={className} aria-hidden="true" />
)}
</button>
);
};

View File

@@ -0,0 +1,29 @@
import Link from "next/link";
import { hardhat } from "viem/chains";
import { useTargetNetwork } from "~~/hooks/scaffold-eth";
type AddressLinkWrapperProps = {
children: React.ReactNode;
disableAddressLink?: boolean;
blockExplorerAddressLink: string;
};
export const AddressLinkWrapper = ({
children,
disableAddressLink,
blockExplorerAddressLink,
}: AddressLinkWrapperProps) => {
const { targetNetwork } = useTargetNetwork();
return disableAddressLink ? (
<>{children}</>
) : (
<Link
href={blockExplorerAddressLink}
target={targetNetwork.id === hardhat.id ? undefined : "_blank"}
rel={targetNetwork.id === hardhat.id ? undefined : "noopener noreferrer"}
>
{children}
</Link>
);
};

View File

@@ -0,0 +1,75 @@
"use client";
import { Address, formatEther } from "viem";
import { useDisplayUsdMode } from "~~/hooks/scaffold-eth/useDisplayUsdMode";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { useWatchBalance } from "~~/hooks/scaffold-eth/useWatchBalance";
import { useGlobalState } from "~~/services/store/store";
type BalanceProps = {
address?: Address;
className?: string;
usdMode?: boolean;
};
/**
* Display (ETH & USD) balance of an ETH address.
*/
export const Balance = ({ address, className = "", usdMode }: BalanceProps) => {
const { targetNetwork } = useTargetNetwork();
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
const isNativeCurrencyPriceFetching = useGlobalState(state => state.nativeCurrency.isFetching);
const {
data: balance,
isError,
isLoading,
} = useWatchBalance({
address,
});
const { displayUsdMode, toggleDisplayUsdMode } = useDisplayUsdMode({ defaultUsdMode: usdMode });
if (!address || isLoading || balance === null || (isNativeCurrencyPriceFetching && nativeCurrencyPrice === 0)) {
return (
<div className="animate-pulse flex space-x-4">
<div className="rounded-md bg-slate-300 h-6 w-6"></div>
<div className="flex items-center space-y-6">
<div className="h-2 w-28 bg-slate-300 rounded-sm"></div>
</div>
</div>
);
}
if (isError) {
return (
<div className="border-2 border-base-content/30 rounded-md px-2 flex flex-col items-center max-w-fit cursor-pointer">
<div className="text-warning">Error</div>
</div>
);
}
const formattedBalance = balance ? Number(formatEther(balance.value)) : 0;
return (
<button
className={`btn btn-sm btn-ghost flex flex-col font-normal items-center hover:bg-transparent ${className}`}
onClick={toggleDisplayUsdMode}
type="button"
>
<div className="w-full flex items-center justify-center">
{displayUsdMode ? (
<>
<span className="text-[0.8em] font-bold mr-1">$</span>
<span>{(formattedBalance * nativeCurrencyPrice).toFixed(2)}</span>
</>
) : (
<>
<span>{formattedBalance.toFixed(4)}</span>
<span className="text-[0.8em] font-bold ml-1">{targetNetwork.nativeCurrency.symbol}</span>
</>
)}
</div>
</button>
);
};

View 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`}
/>
);

View File

@@ -0,0 +1,129 @@
"use client";
import { useEffect, useState } from "react";
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 { Address, AddressInput, Balance, EtherInput } from "~~/components/scaffold-eth";
import { 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 { 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 />
</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" value={sendValue} onChange={value => setSendValue(value)} />
<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>
);
};

View File

@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
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";
import { useWatchBalance } from "~~/hooks/scaffold-eth/useWatchBalance";
// 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 });
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>
);
};

View File

@@ -0,0 +1,112 @@
import { useEffect, useState } from "react";
import { blo } from "blo";
import { useDebounceValue } from "usehooks-ts";
import { Address, isAddress } from "viem";
import { normalize } from "viem/ens";
import { useEnsAddress, useEnsAvatar, useEnsName } from "wagmi";
import { CommonInputProps, InputBase, isENS } from "~~/components/scaffold-eth";
/**
* Address input with ENS name resolution
*/
export const AddressInput = ({ value, name, placeholder, onChange, disabled }: CommonInputProps<Address | string>) => {
// Debounce the input to keep clean RPC calls when resolving ENS names
// If the input is an address, we don't need to debounce it
const [_debouncedValue] = useDebounceValue(value, 500);
const debouncedValue = isAddress(value) ? value : _debouncedValue;
const isDebouncedValueLive = debouncedValue === value;
// If the user changes the input after an ENS name is already resolved, we want to remove the stale result
const settledValue = isDebouncedValueLive ? debouncedValue : undefined;
const {
data: ensAddress,
isLoading: isEnsAddressLoading,
isError: isEnsAddressError,
} = useEnsAddress({
name: settledValue,
chainId: 1,
query: {
gcTime: 30_000,
enabled: isDebouncedValueLive && isENS(debouncedValue),
},
});
const [enteredEnsName, setEnteredEnsName] = useState<string>();
const {
data: ensName,
isLoading: isEnsNameLoading,
isError: isEnsNameError,
} = useEnsName({
address: settledValue as Address,
chainId: 1,
query: {
enabled: isAddress(debouncedValue),
gcTime: 30_000,
},
});
const { data: ensAvatar, isLoading: isEnsAvatarLoading } = useEnsAvatar({
name: ensName ? normalize(ensName) : undefined,
chainId: 1,
query: {
enabled: Boolean(ensName),
gcTime: 30_000,
},
});
// ens => address
useEffect(() => {
if (!ensAddress) return;
// ENS resolved successfully
setEnteredEnsName(debouncedValue);
onChange(ensAddress);
}, [ensAddress, onChange, debouncedValue]);
useEffect(() => {
setEnteredEnsName(undefined);
}, [value]);
const reFocus = isEnsAddressError || isEnsNameError || ensName === null || ensAddress === null;
return (
<InputBase<Address>
name={name}
placeholder={placeholder}
error={ensAddress === null}
value={value as Address}
onChange={onChange}
disabled={isEnsAddressLoading || isEnsNameLoading || disabled}
reFocus={reFocus}
prefix={
ensName ? (
<div className="flex bg-base-300 rounded-l-full items-center">
{isEnsAvatarLoading && <div className="skeleton bg-base-200 w-[35px] h-[35px] rounded-full shrink-0"></div>}
{ensAvatar ? (
<span className="w-[35px]">
{
// eslint-disable-next-line
<img className="w-full rounded-full" src={ensAvatar} alt={`${ensAddress} avatar`} />
}
</span>
) : null}
<span className="text-accent px-2">{enteredEnsName ?? ensName}</span>
</div>
) : (
(isEnsNameLoading || isEnsAddressLoading) && (
<div className="flex bg-base-300 rounded-l-full items-center gap-2 pr-2">
<div className="skeleton bg-base-200 w-[35px] h-[35px] rounded-full shrink-0"></div>
<div className="skeleton bg-base-200 h-3 w-20"></div>
</div>
)
)
}
suffix={
// Don't want to use nextJS Image here (and adding remote patterns for the URL)
// eslint-disable-next-line @next/next/no-img-element
value && <img alt="" className="rounded-full!" src={blo(value as `0x${string}`)} width="35" height="35" />
}
/>
);
};

View File

@@ -0,0 +1,31 @@
import { useCallback } from "react";
import { hexToString, isHex, stringToHex } from "viem";
import { CommonInputProps, InputBase } from "~~/components/scaffold-eth";
export const Bytes32Input = ({ value, onChange, name, placeholder, disabled }: CommonInputProps) => {
const convertStringToBytes32 = useCallback(() => {
if (!value) {
return;
}
onChange(isHex(value) ? hexToString(value, { size: 32 }) : stringToHex(value, { size: 32 }));
}, [onChange, value]);
return (
<InputBase
name={name}
value={value}
placeholder={placeholder}
onChange={onChange}
disabled={disabled}
suffix={
<button
className="self-center cursor-pointer text-xl font-semibold px-4 text-accent"
onClick={convertStringToBytes32}
type="button"
>
#
</button>
}
/>
);
};

View File

@@ -0,0 +1,28 @@
import { useCallback } from "react";
import { bytesToString, isHex, toBytes, toHex } from "viem";
import { CommonInputProps, InputBase } from "~~/components/scaffold-eth";
export const BytesInput = ({ value, onChange, name, placeholder, disabled }: CommonInputProps) => {
const convertStringToBytes = useCallback(() => {
onChange(isHex(value) ? bytesToString(toBytes(value)) : toHex(toBytes(value)));
}, [onChange, value]);
return (
<InputBase
name={name}
value={value}
placeholder={placeholder}
onChange={onChange}
disabled={disabled}
suffix={
<button
className="self-center cursor-pointer text-xl font-semibold px-4 text-accent"
onClick={convertStringToBytes}
type="button"
>
#
</button>
}
/>
);
};

View File

@@ -0,0 +1,128 @@
import { useMemo, useState } from "react";
import { ArrowsRightLeftIcon } from "@heroicons/react/24/outline";
import { CommonInputProps, InputBase, SIGNED_NUMBER_REGEX } from "~~/components/scaffold-eth";
import { useDisplayUsdMode } from "~~/hooks/scaffold-eth/useDisplayUsdMode";
import { useGlobalState } from "~~/services/store/store";
const MAX_DECIMALS_USD = 2;
function etherValueToDisplayValue(usdMode: boolean, etherValue: string, nativeCurrencyPrice: number) {
if (usdMode && nativeCurrencyPrice) {
const parsedEthValue = parseFloat(etherValue);
if (Number.isNaN(parsedEthValue)) {
return etherValue;
} else {
// We need to round the value rather than use toFixed,
// since otherwise a user would not be able to modify the decimal value
return (
Math.round(parsedEthValue * nativeCurrencyPrice * 10 ** MAX_DECIMALS_USD) /
10 ** MAX_DECIMALS_USD
).toString();
}
} else {
return etherValue;
}
}
function displayValueToEtherValue(usdMode: boolean, displayValue: string, nativeCurrencyPrice: number) {
if (usdMode && nativeCurrencyPrice) {
const parsedDisplayValue = parseFloat(displayValue);
if (Number.isNaN(parsedDisplayValue)) {
// Invalid number.
return displayValue;
} else {
// Compute the ETH value if a valid number.
return (parsedDisplayValue / nativeCurrencyPrice).toString();
}
} else {
return displayValue;
}
}
/**
* Input for ETH amount with USD conversion.
*
* onChange will always be called with the value in ETH
*/
export const EtherInput = ({
value,
name,
placeholder,
onChange,
disabled,
usdMode,
}: CommonInputProps & { usdMode?: boolean }) => {
const [transitoryDisplayValue, setTransitoryDisplayValue] = useState<string>();
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
const isNativeCurrencyPriceFetching = useGlobalState(state => state.nativeCurrency.isFetching);
const { displayUsdMode, toggleDisplayUsdMode } = useDisplayUsdMode({ defaultUsdMode: usdMode });
// The displayValue is derived from the ether value that is controlled outside of the component
// In usdMode, it is converted to its usd value, in regular mode it is unaltered
const displayValue = useMemo(() => {
const newDisplayValue = etherValueToDisplayValue(displayUsdMode, value, nativeCurrencyPrice || 0);
if (transitoryDisplayValue && parseFloat(newDisplayValue) === parseFloat(transitoryDisplayValue)) {
return transitoryDisplayValue;
}
// Clear any transitory display values that might be set
setTransitoryDisplayValue(undefined);
return newDisplayValue;
}, [nativeCurrencyPrice, transitoryDisplayValue, displayUsdMode, value]);
const handleChangeNumber = (newValue: string) => {
if (newValue && !SIGNED_NUMBER_REGEX.test(newValue)) {
return;
}
// Following condition is a fix to prevent usdMode from experiencing different display values
// than what the user entered. This can happen due to floating point rounding errors that are introduced in the back and forth conversion
if (displayUsdMode) {
const decimals = newValue.split(".")[1];
if (decimals && decimals.length > MAX_DECIMALS_USD) {
return;
}
}
// Since the display value is a derived state (calculated from the ether value), usdMode would not allow introducing a decimal point.
// This condition handles a transitory state for a display value with a trailing decimal sign
if (newValue.endsWith(".") || newValue.endsWith(".0")) {
setTransitoryDisplayValue(newValue);
} else {
setTransitoryDisplayValue(undefined);
}
const newEthValue = displayValueToEtherValue(displayUsdMode, newValue, nativeCurrencyPrice || 0);
onChange(newEthValue);
};
return (
<InputBase
name={name}
value={displayValue}
placeholder={placeholder}
onChange={handleChangeNumber}
disabled={disabled}
prefix={<span className="pl-4 -mr-2 text-accent self-center">{displayUsdMode ? "$" : "Ξ"}</span>}
suffix={
<div
className={`${
nativeCurrencyPrice > 0
? ""
: "tooltip tooltip-secondary before:content-[attr(data-tip)] before:right-[-10px] before:left-auto before:transform-none"
}`}
data-tip={isNativeCurrencyPriceFetching ? "Fetching price" : "Unable to fetch price"}
>
<button
className="btn btn-primary h-[2.2rem] min-h-[2.2rem]"
onClick={toggleDisplayUsdMode}
disabled={!displayUsdMode && !nativeCurrencyPrice}
type="button"
>
<ArrowsRightLeftIcon className="h-3 w-3 cursor-pointer" aria-hidden="true" />
</button>
</div>
}
/>
);
};

View File

@@ -0,0 +1,66 @@
import { ChangeEvent, FocusEvent, ReactNode, useCallback, useEffect, useRef } from "react";
import { CommonInputProps } from "~~/components/scaffold-eth";
type InputBaseProps<T> = CommonInputProps<T> & {
error?: boolean;
prefix?: ReactNode;
suffix?: ReactNode;
reFocus?: boolean;
};
export const InputBase = <T extends { toString: () => string } | undefined = string>({
name,
value,
onChange,
placeholder,
error,
disabled,
prefix,
suffix,
reFocus,
}: InputBaseProps<T>) => {
const inputReft = useRef<HTMLInputElement>(null);
let modifier = "";
if (error) {
modifier = "border-error";
} else if (disabled) {
modifier = "border-disabled bg-base-300";
}
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
onChange(e.target.value as unknown as T);
},
[onChange],
);
// Runs only when reFocus prop is passed, useful for setting the cursor
// at the end of the input. Example AddressInput
const onFocus = (e: FocusEvent<HTMLInputElement, Element>) => {
if (reFocus !== undefined) {
e.currentTarget.setSelectionRange(e.currentTarget.value.length, e.currentTarget.value.length);
}
};
useEffect(() => {
if (reFocus !== undefined && reFocus === true) inputReft.current?.focus();
}, [reFocus]);
return (
<div className={`flex border-2 border-base-300 bg-base-200 rounded-full text-accent ${modifier}`}>
{prefix}
<input
className="input input-ghost focus-within:border-transparent focus:outline-hidden focus:bg-transparent h-[2.2rem] min-h-[2.2rem] px-4 border w-full font-medium placeholder:text-accent/70 text-base-content/70 focus:text-base-content/70"
placeholder={placeholder}
name={name}
value={value?.toString()}
onChange={handleChange}
disabled={disabled}
autoComplete="off"
ref={inputReft}
onFocus={onFocus}
/>
{suffix}
</div>
);
};

View File

@@ -0,0 +1,63 @@
import { useCallback, useEffect, useState } from "react";
import { parseEther } from "viem";
import { CommonInputProps, InputBase, IntegerVariant, isValidInteger } from "~~/components/scaffold-eth";
type IntegerInputProps = CommonInputProps<string> & {
variant?: IntegerVariant;
disableMultiplyBy1e18?: boolean;
};
export const IntegerInput = ({
value,
onChange,
name,
placeholder,
disabled,
variant = IntegerVariant.UINT256,
disableMultiplyBy1e18 = false,
}: IntegerInputProps) => {
const [inputError, setInputError] = useState(false);
const multiplyBy1e18 = useCallback(() => {
if (!value) {
return;
}
return onChange(parseEther(value).toString());
}, [onChange, value]);
useEffect(() => {
if (isValidInteger(variant, value)) {
setInputError(false);
} else {
setInputError(true);
}
}, [value, variant]);
return (
<InputBase
name={name}
value={value}
placeholder={placeholder}
error={inputError}
onChange={onChange}
disabled={disabled}
suffix={
!inputError &&
!disableMultiplyBy1e18 && (
<div
className="space-x-4 flex tooltip tooltip-top tooltip-secondary before:content-[attr(data-tip)] before:right-[-10px] before:left-auto before:transform-none"
data-tip="Multiply by 1e18 (wei)"
>
<button
className={`${disabled ? "cursor-not-allowed" : "cursor-pointer"} font-semibold px-4 text-accent`}
onClick={multiplyBy1e18}
disabled={disabled}
type="button"
>
</button>
</div>
)
}
/>
);
};

View File

@@ -0,0 +1,9 @@
"use client";
export * from "./AddressInput";
export * from "./Bytes32Input";
export * from "./BytesInput";
export * from "./EtherInput";
export * from "./InputBase";
export * from "./IntegerInput";
export * from "./utils";

View File

@@ -0,0 +1,109 @@
export type CommonInputProps<T = string> = {
value: T;
onChange: (newValue: T) => void;
name?: string;
placeholder?: string;
disabled?: boolean;
};
export enum IntegerVariant {
UINT8 = "uint8",
UINT16 = "uint16",
UINT24 = "uint24",
UINT32 = "uint32",
UINT40 = "uint40",
UINT48 = "uint48",
UINT56 = "uint56",
UINT64 = "uint64",
UINT72 = "uint72",
UINT80 = "uint80",
UINT88 = "uint88",
UINT96 = "uint96",
UINT104 = "uint104",
UINT112 = "uint112",
UINT120 = "uint120",
UINT128 = "uint128",
UINT136 = "uint136",
UINT144 = "uint144",
UINT152 = "uint152",
UINT160 = "uint160",
UINT168 = "uint168",
UINT176 = "uint176",
UINT184 = "uint184",
UINT192 = "uint192",
UINT200 = "uint200",
UINT208 = "uint208",
UINT216 = "uint216",
UINT224 = "uint224",
UINT232 = "uint232",
UINT240 = "uint240",
UINT248 = "uint248",
UINT256 = "uint256",
INT8 = "int8",
INT16 = "int16",
INT24 = "int24",
INT32 = "int32",
INT40 = "int40",
INT48 = "int48",
INT56 = "int56",
INT64 = "int64",
INT72 = "int72",
INT80 = "int80",
INT88 = "int88",
INT96 = "int96",
INT104 = "int104",
INT112 = "int112",
INT120 = "int120",
INT128 = "int128",
INT136 = "int136",
INT144 = "int144",
INT152 = "int152",
INT160 = "int160",
INT168 = "int168",
INT176 = "int176",
INT184 = "int184",
INT192 = "int192",
INT200 = "int200",
INT208 = "int208",
INT216 = "int216",
INT224 = "int224",
INT232 = "int232",
INT240 = "int240",
INT248 = "int248",
INT256 = "int256",
}
export const SIGNED_NUMBER_REGEX = /^-?\d+\.?\d*$/;
export const UNSIGNED_NUMBER_REGEX = /^\.?\d+\.?\d*$/;
export const isValidInteger = (dataType: IntegerVariant, value: string) => {
const isSigned = dataType.startsWith("i");
const bitcount = Number(dataType.substring(isSigned ? 3 : 4));
let valueAsBigInt;
try {
valueAsBigInt = BigInt(value);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {}
if (typeof valueAsBigInt !== "bigint") {
if (!value || typeof value !== "string") {
return true;
}
return isSigned ? SIGNED_NUMBER_REGEX.test(value) || value === "-" : UNSIGNED_NUMBER_REGEX.test(value);
} else if (!isSigned && valueAsBigInt < 0) {
return false;
}
const hexString = valueAsBigInt.toString(16);
const significantHexDigits = hexString.match(/.*x0*(.*)$/)?.[1] ?? "";
if (
significantHexDigits.length * 4 > bitcount ||
(isSigned && significantHexDigits.length * 4 === bitcount && parseInt(significantHexDigits.slice(-1)?.[0], 16) < 8)
) {
return false;
}
return true;
};
// Treat any dot-separated string as a potential ENS name
const ensRegex = /.+\..+/;
export const isENS = (address = "") => ensRegex.test(address);

Some files were not shown because too many files have changed in this diff Show More