Initial commit with 🏗️ Scaffold-ETH 2 @ 1.0.2
This commit is contained in:
112
.cursor/rules/scaffold-eth.mdc
Normal file
112
.cursor/rules/scaffold-eth.mdc
Normal 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
1
.cursorignore
Normal file
@@ -0,0 +1 @@
|
||||
*
|
||||
43
.github/workflows/lint.yaml
vendored
Normal file
43
.github/workflows/lint.yaml
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
name: Lint
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node: [lts/*]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@master
|
||||
|
||||
- name: Setup node env
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: yarn
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --immutable
|
||||
|
||||
- name: Run hardhat node, deploy contracts (& generate contracts typescript output)
|
||||
run: yarn chain & yarn deploy
|
||||
|
||||
- name: Run hardhat lint
|
||||
run: yarn hardhat:lint --max-warnings=0
|
||||
|
||||
- name: Run nextjs lint
|
||||
run: yarn next:lint --max-warnings=0
|
||||
|
||||
- name: Check typings on nextjs
|
||||
run: yarn next:check-types
|
||||
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# yarn
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# eslint
|
||||
.eslintcache
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# cli
|
||||
dist
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
yarn lint-staged --verbose
|
||||
21
.lintstagedrc.js
Normal file
21
.lintstagedrc.js
Normal file
@@ -0,0 +1,21 @@
|
||||
const path = require("path");
|
||||
|
||||
const buildNextEslintCommand = (filenames) =>
|
||||
`yarn next:lint --fix --file ${filenames
|
||||
.map((f) => path.relative(path.join("packages", "nextjs"), f))
|
||||
.join(" --file ")}`;
|
||||
|
||||
const checkTypesNextCommand = () => "yarn next:check-types";
|
||||
|
||||
const buildHardhatEslintCommand = (filenames) =>
|
||||
`yarn hardhat:lint-staged --fix ${filenames
|
||||
.map((f) => path.relative(path.join("packages", "hardhat"), f))
|
||||
.join(" ")}`;
|
||||
|
||||
module.exports = {
|
||||
"packages/nextjs/**/*.{ts,tsx}": [
|
||||
buildNextEslintCommand,
|
||||
checkTypesNextCommand,
|
||||
],
|
||||
"packages/hardhat/**/*.{ts,tsx}": [buildHardhatEslintCommand],
|
||||
};
|
||||
541
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
541
.yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
9
.yarn/plugins/@yarnpkg/plugin-typescript.cjs
vendored
Normal file
9
.yarn/plugins/@yarnpkg/plugin-typescript.cjs
vendored
Normal file
File diff suppressed because one or more lines are too long
783
.yarn/releases/yarn-3.2.3.cjs
vendored
Executable file
783
.yarn/releases/yarn-3.2.3.cjs
vendored
Executable file
File diff suppressed because one or more lines are too long
13
.yarnrc.yml
Normal file
13
.yarnrc.yml
Normal file
@@ -0,0 +1,13 @@
|
||||
enableColors: true
|
||||
|
||||
nmHoistingLimits: workspaces
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
plugins:
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-typescript.cjs
|
||||
spec: "@yarnpkg/plugin-typescript"
|
||||
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
|
||||
spec: "@yarnpkg/plugin-interactive-tools"
|
||||
|
||||
yarnPath: .yarn/releases/yarn-3.2.3.cjs
|
||||
86
CONTRIBUTING.md
Normal file
86
CONTRIBUTING.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# Welcome to Scaffold-ETH 2 Contributing Guide
|
||||
|
||||
Thank you for investing your time in contributing to Scaffold-ETH 2!
|
||||
|
||||
This guide aims to provide an overview of the contribution workflow to help us make the contribution process effective for everyone involved.
|
||||
|
||||
## About the Project
|
||||
|
||||
Scaffold-ETH 2 is a minimal and forkable repo providing builders with a starter kit to build decentralized applications on Ethereum.
|
||||
|
||||
Read the [README](README.md) to get an overview of the project.
|
||||
|
||||
### Vision
|
||||
|
||||
The goal of Scaffold-ETH 2 is to provide the primary building blocks for a decentralized application.
|
||||
|
||||
The repo can be forked to include integrations and more features, but we want to keep the master branch simple and minimal.
|
||||
|
||||
### Project Status
|
||||
|
||||
The project is under active development.
|
||||
|
||||
You can view the open Issues, follow the development process and contribute to the project.
|
||||
|
||||
## Getting started
|
||||
|
||||
You can contribute to this repo in many ways:
|
||||
|
||||
- Solve open issues
|
||||
- Report bugs or feature requests
|
||||
- Improve the documentation
|
||||
|
||||
Contributions are made via Issues and Pull Requests (PRs). A few general guidelines for contributions:
|
||||
|
||||
- Search for existing Issues and PRs before creating your own.
|
||||
- Contributions should only fix/add the functionality in the issue OR address style issues, not both.
|
||||
- If you're running into an error, please give context. Explain what you're trying to do and how to reproduce the error.
|
||||
- Please use the same formatting in the code repository. You can configure your IDE to do it by using the prettier / linting config files included in each package.
|
||||
- If applicable, please edit the README.md file to reflect the changes.
|
||||
|
||||
### Issues
|
||||
|
||||
Issues should be used to report problems, request a new feature, or discuss potential changes before a PR is created.
|
||||
|
||||
#### Solve an issue
|
||||
|
||||
Scan through our [existing issues](https://github.com/scaffold-eth/scaffold-eth-2/issues) to find one that interests you.
|
||||
|
||||
If a contributor is working on the issue, they will be assigned to the individual. If you find an issue to work on, you are welcome to assign it to yourself and open a PR with a fix for it.
|
||||
|
||||
#### Create a new issue
|
||||
|
||||
If a related issue doesn't exist, you can open a new issue.
|
||||
|
||||
Some tips to follow when you are creating an issue:
|
||||
|
||||
- Provide as much context as possible. Over-communicate to give the most details to the reader.
|
||||
- Include the steps to reproduce the issue or the reason for adding the feature.
|
||||
- Screenshots, videos etc., are highly appreciated.
|
||||
|
||||
### Pull Requests
|
||||
|
||||
#### Pull Request Process
|
||||
|
||||
We follow the ["fork-and-pull" Git workflow](https://github.com/susam/gitpr)
|
||||
|
||||
1. Fork the repo
|
||||
2. Clone the project
|
||||
3. Create a new branch with a descriptive name
|
||||
4. Commit your changes to the new branch
|
||||
5. Push changes to your fork
|
||||
6. Open a PR in our repository and tag one of the maintainers to review your PR
|
||||
|
||||
Here are some tips for a high-quality pull request:
|
||||
|
||||
- Create a title for the PR that accurately defines the work done.
|
||||
- Structure the description neatly to make it easy to consume by the readers. For example, you can include bullet points and screenshots instead of having one large paragraph.
|
||||
- Add the link to the issue if applicable.
|
||||
- Have a good commit message that summarises the work done.
|
||||
|
||||
Once you submit your PR:
|
||||
|
||||
- We may ask questions, request additional information or ask for changes to be made before a PR can be merged. Please note that these are to make the PR clear for everyone involved and aims to create a frictionless interaction process.
|
||||
- As you update your PR and apply changes, mark each conversation resolved.
|
||||
|
||||
Once the PR is approved, we'll "squash-and-merge" to keep the git commit history clean.
|
||||
21
LICENCE
Normal file
21
LICENCE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 BuidlGuidl
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
802
README.md
Normal file
802
README.md
Normal file
@@ -0,0 +1,802 @@
|
||||
# 🏗 Scaffold-ETH 2
|
||||
|
||||
<h4 align="center">
|
||||
<a href="https://docs.scaffoldeth.io">Documentation</a> |
|
||||
<a href="https://scaffoldeth.io">Website</a>
|
||||
</h4>
|
||||
|
||||
🧪 An open-source, up-to-date toolkit for building decentralized applications (dapps) on the Ethereum blockchain. It's designed to make it easier for developers to create and deploy smart contracts and build user interfaces that interact with those contracts.
|
||||
|
||||
⚙️ Built using NextJS, RainbowKit, Hardhat, Wagmi, Viem, and Typescript.
|
||||
|
||||
- ✅ **Contract Hot Reload**: Your frontend auto-adapts to your smart contract as you edit it.
|
||||
- 🪝 **[Custom hooks](https://docs.scaffoldeth.io/hooks/)**: Collection of React hooks wrapper around [wagmi](https://wagmi.sh/) to simplify interactions with smart contracts with typescript autocompletion.
|
||||
- 🧱 [**Components**](https://docs.scaffoldeth.io/components/): Collection of common web3 components to quickly build your frontend.
|
||||
- 🔥 **Burner Wallet & Local Faucet**: Quickly test your application with a burner wallet and local faucet.
|
||||
- 🔐 **Integration with Wallet Providers**: Connect to different wallet providers and interact with the Ethereum network.
|
||||
|
||||

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

|
||||
|
||||
This challenge will help you build/understand a simple decentralized exchange, with one token-pair (ERC20 BALLOONS ($BAL) and ETH). This repo is an updated version of the [original tutorial](https://medium.com/@austin_48503/%EF%B8%8F-minimum-viable-exchange-d84f30bd0c90) and challenge repos before it. Please read the intro for a background on what we are building first!
|
||||
|
||||
🌟 The final deliverable is an app that allows users to seamlessly trade ERC20 BALLOONS ($BAL) with ETH in a decentralized manner. Users will be able to connect their wallets, view their token balances, and buy or sell their tokens according to a price formula!
|
||||
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)!
|
||||
|
||||
There is also a 🎥 [Youtube video](https://www.youtube.com/watch?v=eP5w6Ger1EQ) that may help you understand the concepts covered within this challenge too:
|
||||
|
||||
💬 Meet other builders working on this challenge and get help in the [Challenge Telegram](https://t.me/+_NeUIJ664Tc1MzIx)
|
||||
|
||||
---
|
||||
|
||||
## 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 to see the app.
|
||||
|
||||
> 👩💻 Rerun `yarn deploy --reset` whenever you want to deploy new contracts to the frontend, update your current contracts with changes, or re-deploy it to get a fresh contract address.
|
||||
|
||||
---
|
||||
|
||||
⚠️ We have disabled AI in Cursor and VSCode and highly suggest that you do not enable it so you can focus on the challenge, do everything by yourself, and hence better understand and remember things. If you are using another IDE, please disable AI yourself.
|
||||
|
||||
🔧 If you are a vibe-coder and don't care about understanding the syntax of the code used and just want to understand the general takeaways, you can re-enable AI by:
|
||||
- Cursor: remove `*` from `.cursorignore` file
|
||||
- VSCode: set `chat.disableAIFeatures` to `false` in `.vscode/settings.json` file
|
||||
|
||||
---
|
||||
|
||||
## Checkpoint 1: 🔭 The Structure 📺
|
||||
|
||||
Navigate to the `Debug Contracts` tab, you should see two smart contracts displayed called `DEX` and `Balloons`.
|
||||
|
||||
`packages/hardhat/contracts/Balloons.sol` is just an example ERC20 contract that mints 1000 $BAL to whatever address deploys it.
|
||||
|
||||
`packages/hardhat/contracts/DEX.sol` is what we will build in this challenge and you can see it starts instantiating a token (ERC20 interface) that we set in the constructor (on deploy).
|
||||
|
||||
> Below is what your front-end will look like with no implementation code within your smart contracts yet. The buttons will likely break because there are no functions tied to them yet!
|
||||
|
||||

|
||||
|
||||
> 🎉 You've made it this far in Scaffold-Eth Challenges 👏🏼 . As things get more complex, it might be good to review the design requirements of the challenge first!
|
||||
> Check out the empty `DEX.sol` file to see aspects of each function. If you can explain how each function will work with one another, that's great! 😎
|
||||
|
||||
> 🚨 🚨 🦈 **The Guiding Questions will lead you in the right direction, but try thinking about how you would structure each function before looking at these!**
|
||||
|
||||
> 🚨 🚨 🦖 **The code blobs within the toggles in the Guiding Questions are some examples of what you can use, but try writing the implementation code for the functions first!**
|
||||
|
||||
---
|
||||
|
||||
## Checkpoint 2: Reserves ⚖️
|
||||
|
||||
We want to create an automatic market where our contract will hold reserves of both ETH and 🎈 Balloons. These reserves will provide liquidity that allows anyone to swap between the assets. Let's start with declaring our `totalLiquidity` and the `liquidity` of each user of our DEX!
|
||||
|
||||
<details markdown='1'><summary>🦉 Guiding Questions</summary>
|
||||
|
||||
<details markdown='1'><summary>Question One</summary>
|
||||
|
||||
> How do we declare a variable that represents an amount of ETH? We don't have to assign it a value just yet.
|
||||
|
||||
</details>
|
||||
<details markdown='1'><summary>Question Two</summary>
|
||||
|
||||
> What data structure represents the relation between keys and values (addresses to liquidity or users to ETH)?
|
||||
|
||||
</details>
|
||||
|
||||
After thinking through the guiding questions, have a look at the solution code!
|
||||
|
||||
<details markdown='1'><summary>👩🏽🏫 Solution Code</summary>
|
||||
|
||||
```
|
||||
uint256 public totalLiquidity;
|
||||
mapping (address => uint256) public liquidity;
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
These variables track the total liquidity, but also the liquidity of each address.
|
||||
Now, let's create an `init()` function in `DEX.sol`.
|
||||
We want this function written in a way that when we send ETH and/or $BAL tokens through our front end or deployer script, the function will get those values from the contract and assign them onto the global variables we just defined.
|
||||
|
||||
<details markdown='1'><summary>🦉 Guiding Questions</summary>
|
||||
|
||||
<details markdown='1'><summary>Question One</summary>
|
||||
|
||||
> How can we check and prevent liquidity being added if the contract already has liquidity?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Two</summary>
|
||||
|
||||
> What should the value of `totalLiquidity` be, how do we access the balance that our contract has and assign the variable a value?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Three</summary>
|
||||
|
||||
> How would we assign our address the liquidity we just provided? How much liquidity have we provided? The `totalLiquidity`? Just half? Three quarters?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Four</summary>
|
||||
|
||||
> Now we need to take care of the tokens `init()` is receiving. How do we transfer the tokens from the sender (us) to this contract address? How do we make sure the transaction reverts if the sender did not have as many tokens as they wanted to send?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary> 👨🏻🏫 Solution Code</summary>
|
||||
|
||||
```
|
||||
function init(uint256 tokens) public payable returns (uint256) {
|
||||
require(totalLiquidity == 0, "DEX: init - already has liquidity");
|
||||
totalLiquidity = address(this).balance;
|
||||
liquidity[msg.sender] = totalLiquidity;
|
||||
require(token.transferFrom(msg.sender, address(this), tokens), "DEX: init - transfer did not transact");
|
||||
return totalLiquidity;
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
Calling `init()` will load our contract up with both ETH and 🎈 Balloons.
|
||||
|
||||
We can see that the DEX starts empty. We want to be able to call `init()` to start it off with liquidity, but we don’t have any funds or tokens yet. Add some ETH to your local account using the faucet and then find the `00_deploy_your_contract.ts` file. Find and uncomment the lines below and add your front-end address (your burner wallet address).
|
||||
|
||||
```
|
||||
// // paste in your front-end address here to get 10 balloons on deploy:
|
||||
// await balloons.transfer("YOUR_FRONTEND_ADDRESS", "" + 10 * 10 ** 18);
|
||||
```
|
||||
|
||||
> Run `yarn deploy`.
|
||||
|
||||
The front end should show you that you have balloon tokens. We can’t just call `init()` yet because the DEX contract isn’t allowed to transfer ERC20 tokens from our account.
|
||||
|
||||
First, we have to call `approve()` on the Balloons contract, approving the DEX contract address to take some amount of tokens.
|
||||
|
||||

|
||||
|
||||
> 🤓 Copy and paste the DEX address to the _Address Spender_ and then set the amount to 5.
|
||||
> You can confirm this worked using the `allowance()` function in `Debug Contracts` tab using your local account address as the owner and the DEX contract address as the spender.
|
||||
|
||||
Now we are ready to call `init()` on the DEX, using the `Debug Contracts` tab. We will tell it to take 5 of our tokens and send 0.01 ETH with the transaction. Remember in the `Debug Contracts` tab we are calling the functions directly which means we have to convert to wei, so don't forget to multiply those values by 10¹⁸!
|
||||
|
||||

|
||||
|
||||
In the `DEX` tab, to simplify user interactions, we run the conversion (_tokenAmount_ * 10¹⁸) in the code, so they just have to input the token amount they want to swap or deposit/withdraw.
|
||||
|
||||
You can see the DEX contract's value update, and you can check the DEX token balance using the `balanceOf` function on the Balloons UI from `DEX` tab.
|
||||
|
||||
This works pretty well, but it will be a lot easier if we just call the `init()` function as we deploy the contract. In the `00_deploy_your_contract.ts` script try uncommenting the init section, so our DEX will start with 5 ETH and 5 Balloons of liquidity:
|
||||
|
||||
```
|
||||
// // uncomment to init DEX on deploy:
|
||||
|
||||
// const dexAddress = await dex.getAddress();
|
||||
// console.log("Approving DEX (" + dexAddress + ") to take Balloons from main account...");
|
||||
// // If you are going to the testnet make sure your deployer account has enough ETH
|
||||
// await balloons.approve(dexAddress, hre.ethers.parseEther("100"));
|
||||
// console.log("INIT exchange...");
|
||||
// await dex.init(hre.ethers.parseEther("5"), {
|
||||
// value: hre.ethers.parseEther("5"),
|
||||
// gasLimit: 200000,
|
||||
// });
|
||||
```
|
||||
|
||||
Now, when we `yarn deploy --reset` then our contract should be initialized as soon as it deploys, and we should have equal reserves of ETH and tokens.
|
||||
|
||||
### 🥅 Goals / Checks
|
||||
|
||||
- [ ] 🎈 In the DEX tab is your contract showing 5 ETH and 5 Balloons of liquidity?
|
||||
- [ ] ⚠ If you are planning to submit the challenge, make sure to implement the `getLiquidity` getter function in `DEX.sol`
|
||||
|
||||
---
|
||||
|
||||
## ⛳️ **Checkpoint 3: Price** 🤑
|
||||
|
||||
This section is directly from the [original tutorial](https://medium.com/@austin_48503/%EF%B8%8F-minimum-viable-exchange-d84f30bd0c90) "Price" section. It outlines the general details of the DEX's pricing model.
|
||||
|
||||
If you need some more clarity on how the price in a pool is calculated, [this video](https://youtu.be/IL7cRj5vzEU) by Smart Contract Programmer has a more in-depth explanation.
|
||||
|
||||
Now that our contract holds reserves of both ETH and tokens, we want to use a simple formula to determine the exchange rate between the two.
|
||||
Let’s start with the formula `x * y = k` where `x` and `y` are the reserves:
|
||||
|
||||
```
|
||||
(amount of ETH in DEX ) * ( amount of tokens in DEX ) = k
|
||||
```
|
||||
|
||||
The `k` is called an invariant because it doesn’t change during trades. (The `k` only changes as liquidity is added.) If we plot this formula, we’ll get a curve that looks something like:
|
||||
|
||||

|
||||
|
||||
> 💡 We are just swapping one asset for another, the “price” is basically how much of the resulting output asset you will get if you put in a certain amount of the input asset.
|
||||
|
||||
🤔 OH! A market based on a curve like this will always have liquidity, but as the ratio becomes more and more unbalanced, you will get less and less of the less-liquid asset from the same trade amount. Again, if the smart contract has too much ETH and not enough $BAL tokens, the price to swap $BAL tokens to ETH should be more desirable.
|
||||
|
||||
When we call `init()` we passed in ETH and $BAL tokens at a ratio of 1:1. As the reserves of one asset changes, the other asset must also change inversely in order to maintain the constant product formula (invariant `k`).
|
||||
|
||||
Now, try to edit your `DEX.sol` smart contract and bring in a price function!
|
||||
|
||||
The price function should take in the reserves of `xReserves`, `yReserves`, and `xInput` to calculate the `yOutput`.
|
||||
Don't forget about trading fees! These fees are important to incentivize liquidity providers. Let's make the trading fee 0.3% and remember that there are no floats or decimals in Solidity, only whole numbers!
|
||||
|
||||
We should apply the fee to `xInput`, and store it in a new variable `xInputWithFee`. We want the input value to pay the fee immediately, or else we will accidentally tax our `yOutput` or our DEX's supply `k` 😨 Think about how to apply a 0.3% to our `xInput`.
|
||||
|
||||
> 💡 _Hints:_ For more information on calculating the Output Reserve, read the Brief Revisit of Uniswap V2 in [this article](https://hackernoon.com/formulas-of-uniswap-a-deep-dive).
|
||||
|
||||
> 💡💡 _More Hints:_ Also, don't forget to think about how to implement the trading fee. Solidity doesn't allow for decimals, so one way that contracts are written to implement percentage is using whole uints (997 and 1000) as numerator and denominator factors, respectively.
|
||||
|
||||
<details markdown='1'><summary>🦉 Guided Explanation</summary>
|
||||
|
||||
For the math portions of this challenge, you can black-box the math. However, it's still important to understand what the math looks like, but maybe less so how it works or why it works, in other words don't get too caught up in the mathematical details! 😅 Look at articles and videos in this challenge or on your own to find out more if you're curious though! 🤓
|
||||
|
||||
1. We are multiplying `xInput` by 997 to "simulate" a multiplication by 0.997 since we can't use decimals in solidity. We'll divide by 1000 later to get the fee back to normal.
|
||||
2. Next, we'll make our `numerator` by multiplying `xInputWithFee` by `yReserves`.
|
||||
3. Then our `denominator` will be `xReserves` multiplied by 1000 (to account for the 997 in the numerator) plus `xInputWithFee`.
|
||||
4. Last, we will return the `numerator` / `denominator` which is our `yOutput`, or the amount of swapped currency. But wait, can we have decimals in Solidity? No, so the output will be rounded up or down to the nearest whole number.
|
||||
|
||||
<details markdown='1'><summary>👩🏽🏫 Solution Code</summary>
|
||||
|
||||
```
|
||||
|
||||
function price(
|
||||
uint256 xInput,
|
||||
uint256 xReserves,
|
||||
uint256 yReserves
|
||||
) public pure returns (uint256 yOutput) {
|
||||
uint256 xInputWithFee = xInput * 997;
|
||||
uint256 numerator = xInputWithFee * yReserves;
|
||||
uint256 denominator = (xReserves * 1000) + xInputWithFee;
|
||||
return (numerator / denominator);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
We use the ratio of the input vs output reserve to calculate the price to swap either asset for the other. Let’s deploy this and poke around:
|
||||
|
||||
```
|
||||
yarn run deploy
|
||||
```
|
||||
|
||||
Let’s say we have 1 million ETH and 1 million tokens, if we put this into our price formula and ask it the price of 1000 ETH it will be an almost 1:1 ratio:
|
||||
|
||||

|
||||
|
||||
If we put in 1000 ETH, we will receive 996 tokens. If we’re paying a 0.3% fee, it should be 997 if everything was perfect. BUT, there is a tiny bit of slippage as our contract moves away from the original ratio. Let’s dig in more to really understand what is going on here.
|
||||
Let’s say there is 5 million ETH and only 1 million tokens. Then, we want to put 1000 tokens in. That means we should receive about 5000 ETH:
|
||||
|
||||

|
||||
|
||||
Finally, let’s say the ratio is the same, but we want to swap 100,000 tokens instead of just 1000. We’ll notice that the amount of slippage is much bigger. Instead of 498,000 back, we will only get 453,305 because we are making such a big dent in the reserves.
|
||||
|
||||

|
||||
|
||||
❗️ The contract automatically adjusts the price as the ratio of reserves shifts away from the equilibrium. It’s called an 🤖 _Automated Market Maker (AMM)._
|
||||
|
||||
### 🥅 Goals / Checks
|
||||
|
||||
- [ ] 🤔 Do you understand how the x*y=k price curve actually works? Write down a clear explanation for yourself and derive the formula for price. You might have to shake off some old algebra skills!
|
||||
- [ ] 💃 You should be able to go through the price section of this tutorial with the sample numbers and generate the same outputChange variable.
|
||||
|
||||
---
|
||||
|
||||
## Checkpoint 4: Trading 🤝
|
||||
|
||||
Let’s edit the `DEX.sol` smart contract and add two new functions for swapping from each asset to the other, `ethToToken()` and `tokenToEth()`.
|
||||
|
||||
The basic overview for `ethToToken()` is we're going to define our variables to pass into `price()` so we can calculate what the user's `tokenOutput` is.
|
||||
|
||||
<details markdown='1'><summary>🦉 Guiding Questions</summary>
|
||||
|
||||
<details markdown='1'><summary>Question One</summary>
|
||||
|
||||
> How would we make sure the value being swapped for balloons is greater than 0? Also, how do we check if they have enough ETH and have given sufficient allowance to the contract?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Two</summary>
|
||||
|
||||
> Is `xReserves` ETH or $BAL tokens? Use a variable name that best describes which one it is. When we call this function, it will already have the value we sent it in it's `liquidity`. How can we make sure we are using the balance of the contract _before_ any ETH was sent to it?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Three</summary>
|
||||
|
||||
> For `yReserves` we will also want to create a new more descriptive variable name. How do we find the other asset balance this address has?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Four</summary>
|
||||
|
||||
> Now that we have all our arguments, how do we call `price()` and store the returned value in a new variable? What kind of name would best describe this variable?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Five</summary>
|
||||
|
||||
> After getting how many tokens the sender should receive, how do we transfer those tokens to the sender?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Six</summary>
|
||||
|
||||
> Which event should we emit for this function?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Seven</summary>
|
||||
|
||||
> Last, what do we return?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>👨🏻🏫 Solution Code </summary>
|
||||
|
||||
```
|
||||
/**
|
||||
* @notice sends Ether to DEX in exchange for $BAL
|
||||
*/
|
||||
function ethToToken() public payable returns (uint256 tokenOutput) {
|
||||
require(msg.value > 0, "cannot swap 0 ETH");
|
||||
uint256 ethReserve = address(this).balance - msg.value;
|
||||
uint256 tokenReserve = token.balanceOf(address(this));
|
||||
tokenOutput = price(msg.value, ethReserve, tokenReserve);
|
||||
|
||||
require(token.transfer(msg.sender, tokenOutput), "ethToToken(): reverted swap.");
|
||||
emit EthToTokenSwap(msg.sender, tokenOutput, msg.value);
|
||||
return tokenOutput;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
😎 Great now onto the next! `tokenToEth()` is going to do the opposite so it should be pretty straight forward. But if you get stuck, the guiding questions are always there 🦉
|
||||
|
||||
<details markdown='1'><summary>🦉 Guiding Questions</summary>
|
||||
|
||||
<details markdown='1'><summary>Question One</summary>
|
||||
|
||||
> How would we make sure the value being swapped for ETH is greater than 0? Also, how do we check if the user have enough tokens and the contract has enough allowance?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Two</summary>
|
||||
|
||||
> Is `xReserves` ETH or $BAL tokens this time? Use a variable name the describes which one it is.
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Three</summary>
|
||||
|
||||
> For `yReserves` we will also want to create a new and more descriptive variable name. How do we find the other asset balance this address has?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Four</summary>
|
||||
|
||||
> Now that we have all our arguments, how do we call `price()` and store the returned value in a new variable?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Five</summary>
|
||||
|
||||
> After getting how much ETH the sender should receive, how do we transfer the ETH to the sender?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Six</summary>
|
||||
|
||||
> Which event do we emit for this function?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Seven</summary>
|
||||
|
||||
> Lastly, what are we returning?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>👨🏻🏫 Solution Code </summary>
|
||||
|
||||
```
|
||||
/**
|
||||
* @notice sends $BAL tokens to DEX in exchange for Ether
|
||||
*/
|
||||
function tokenToEth(uint256 tokenInput) public returns (uint256 ethOutput) {
|
||||
require(tokenInput > 0, "cannot swap 0 tokens");
|
||||
require(token.balanceOf(msg.sender) >= tokenInput, "insufficient token balance");
|
||||
require(token.allowance(msg.sender, address(this)) >= tokenInput, "insufficient allowance");
|
||||
uint256 tokenReserve = token.balanceOf(address(this));
|
||||
ethOutput = price(tokenInput, tokenReserve, address(this).balance);
|
||||
require(token.transferFrom(msg.sender, address(this), tokenInput), "tokenToEth(): reverted swap.");
|
||||
(bool sent, ) = msg.sender.call{ value: ethOutput }("");
|
||||
require(sent, "tokenToEth: revert in transferring eth to you!");
|
||||
emit TokenToEthSwap(msg.sender, tokenInput, ethOutput);
|
||||
return ethOutput;
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
> 💡 Each of these functions should calculate the resulting amount of output asset using our price function that looks at the ratio of the reserves vs the input asset. We can call tokenToEth and it will take our tokens and send us ETH or we can call ethToToken with some ETH in the transaction and it will send us $BAL tokens. Deploy it and try it out!
|
||||
|
||||
### 🥅 Goals / Checks
|
||||
|
||||
- [ ] Can you trade ETH for Balloons and get the correct amount?
|
||||
- [ ] Can you trade Balloons for ETH?
|
||||
|
||||
> ⚠ When trading Balloons for ETH remember about allowances. Try using `approve()` to approve the contract address for some amount of tokens, then try the trade again!
|
||||
|
||||
---
|
||||
|
||||
## Checkpoint 5: Liquidity 🌊
|
||||
|
||||
So far, only the `init()` function controls liquidity. To make this more decentralized, it would be better if anyone could add to the liquidity pool by sending the DEX both ETH and tokens at the correct ratio.
|
||||
|
||||
Let’s create two new functions that let us deposit and withdraw liquidity. How would you write this function out? Try before taking a peak!
|
||||
|
||||
> 💬 _Hint:_
|
||||
|
||||
> The `deposit()` function receives ETH and also transfers $BAL tokens from the caller to the contract at the right ratio. The contract also tracks the amount of liquidity (how many liquidity provider tokens (LPTs) minted) the depositing address owns vs the totalLiquidity.
|
||||
|
||||
What does this hint mean in practice? The goal is to allow a user to `deposit()` ETH into our `totalLiquidity`, and update their `liquidity`. This is very similar to the `init()` function, except we want it to work for anyone providing liquidity. Also, since there already is liquidity we want the liquidity they provide to leave the ratio of the two assets unchanged.
|
||||
|
||||
<details markdown='1'><summary>🦉 Guiding Questions</summary>
|
||||
|
||||
Part 1: Getting Reserves 🏦
|
||||
|
||||
<details markdown='1'><summary>Question One</summary>
|
||||
|
||||
> How do we ensure the sender isn't sending 0 ETH? Also, how do we check if they have enough tokens and have given sufficient allowance to the contract?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Two</summary>
|
||||
|
||||
> We need to calculate the ratio of ETH and $BAL after the liquidity provider sends ETH, what variables do we need? It's similar to the previous section. What was that operation we performed on `ethReserve` in Checkpoint 4 to make sure we were getting the balance _before_ the `msg.value` went through? We need to do that again for the same reason.
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Three</summary>
|
||||
|
||||
> What other asset do we need to declare a reserve for, and how do we get its balance in this contract?
|
||||
|
||||
</details>
|
||||
|
||||
- [ ] Do you have reserves of both assets?
|
||||
|
||||
Part 2: Performing Calculations 🤖
|
||||
|
||||
> What are we calculating again? Oh yeah, for the amount of ETH the user is depositing, we want them to also deposit a proportional amount of tokens. Let's make a reusable equation where we can swap out a value and get an output of the ETH and $BAL the user will be depositing, named `tokenDeposit` and `liquidityMinted`.
|
||||
|
||||
<details markdown='1'><summary>Question Four</summary>
|
||||
|
||||
> How do we calculate how many tokens the user needs to deposit? You multiply the value the user sends through by reserves of the units we want as an output. Then we divide by `ethReserve` and add 1 to the result.
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Five</summary>
|
||||
|
||||
> Now for `liquidityMinted` use the same equation but replace `tokenReserve` with `totalLiquidity`, so that we are multiplying in the numerator by the units we want.
|
||||
|
||||
</details>
|
||||
|
||||
- [ ] Is `tokenDeposit` assigned the value of our equation?
|
||||
- [ ] Now is `liquidityMinted` looking similar to `tokenDeposit` but without the `+ 1` at the end?
|
||||
|
||||
Part 3: Updating, Transferring, Emitting, and Returning 🎀
|
||||
|
||||
<details markdown='1'><summary>Question Six</summary>
|
||||
|
||||
> Now that the DEX has more assets, should we update our two global variables? How do we update `liquidity`?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Seven</summary>
|
||||
|
||||
> How do we update `totalLiquidity`?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Eight</summary>
|
||||
|
||||
> The user already deposited their ETH, but they still have to deposit their tokens. How do we require a token transfer from them?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Nine</summary>
|
||||
|
||||
> We just completed something important, which event should we emit?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Ten</summary>
|
||||
|
||||
> What do we return?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>👩🏽🏫 Solution Code </summary>
|
||||
|
||||
```
|
||||
function deposit() public payable returns (uint256 tokensDeposited) {
|
||||
require(msg.value > 0, "Must send value when depositing");
|
||||
uint256 ethReserve = address(this).balance - msg.value;
|
||||
uint256 tokenReserve = token.balanceOf(address(this));
|
||||
uint256 tokenDeposit;
|
||||
|
||||
tokenDeposit = (msg.value * tokenReserve / ethReserve) + 1;
|
||||
// 💡 Discussion on adding 1 wei at end of calculation ^
|
||||
// -> https://t.me/c/1655715571/106
|
||||
|
||||
require(token.balanceOf(msg.sender) >= tokenDeposit, "insufficient token balance");
|
||||
require(token.allowance(msg.sender, address(this)) >= tokenDeposit, "insufficient allowance");
|
||||
|
||||
uint256 liquidityMinted = msg.value * totalLiquidity / ethReserve;
|
||||
liquidity[msg.sender] += liquidityMinted;
|
||||
totalLiquidity += liquidityMinted;
|
||||
|
||||
require(token.transferFrom(msg.sender, address(this), tokenDeposit));
|
||||
emit LiquidityProvided(msg.sender, liquidityMinted, msg.value, tokenDeposit);
|
||||
return tokenDeposit;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
> 💡 **Remember**: Every time you perform actions with your $BAL tokens (deposit, exchange), you'll need to call `approve()` from the `Balloons.sol` contract **to authorize the DEX address to handle a specific number of your $BAL tokens**. To keep things simple, you can just do that from `Debug Contracts` tab, **ensure you approve a large enough quantity of tokens to not face allowance problems**.
|
||||
|
||||
> 💬💬 _More Hints:_ The `withdraw()` function lets a user take his Liquidity Provider Tokens out, withdrawing both ETH and $BAL tokens out at the correct ratio. The actual amount of ETH and tokens a liquidity provider withdraws could be higher than what they deposited because of the 0.3% fees collected from each trade. It also could be lower depending on the price fluctuations of $BAL to ETH and vice versa (from token swaps taking place using your AMM!). The 0.3% fee incentivizes third parties to provide liquidity, but they must be cautious of [Impermanent Loss (IL)](https://www.youtube.com/watch?v=8XJ1MSTEuU0&t=2s&ab_channel=Finematics).
|
||||
|
||||
<details markdown='1'><summary>🦉 Guiding Questions</summary>
|
||||
|
||||
Part 1: Getting Reserves 🏦
|
||||
|
||||
<details markdown='1'><summary>Question One</summary>
|
||||
|
||||
> How can we verify that a user is withdrawing an `amount` of `liquidity` that they actually have?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Two</summary>
|
||||
|
||||
> Just like the `deposit()` function we need both assets. How much ETH does our DEX have? Remember, this function is not payable, so we don't have to subtract anything.
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Three</summary>
|
||||
|
||||
> What is the value of `tokenReserve`?
|
||||
|
||||
</details>
|
||||
|
||||
Part 2: Performing Calculations 🤖
|
||||
|
||||
> We need to calculate how much of each asset our user is going withdraw, call them `ethWithdrawn` and `tokenAmount`. The equation is: `amount *` reserveOfDesiredUnits `/ totalLiquidity`
|
||||
|
||||
<details markdown='1'><summary>Question Four</summary>
|
||||
|
||||
> How do we get `ethWithdrawn`?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Five</summary>
|
||||
|
||||
> How do we get `tokenOutput`?
|
||||
|
||||
</details>
|
||||
|
||||
Part 3: Updating, Transferring, Emitting, and Returning 🎀
|
||||
|
||||
<details markdown='1'><summary>Question Six</summary>
|
||||
|
||||
> The user is withdrawing, how do we represent this decrease in this individual's `liquidity`?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Seven</summary>
|
||||
|
||||
> The DEX also lost liquidity, how should we update `totalLiquidity`?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Eight</summary>
|
||||
|
||||
> How do pay the user the value of `ethWithdrawn`?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Nine</summary>
|
||||
|
||||
> How do we give them their tokens?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Ten</summary>
|
||||
|
||||
> We have an event to emit, which one?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>Question Eleven</summary>
|
||||
|
||||
> Last, what are we returning?
|
||||
|
||||
</details>
|
||||
|
||||
<details markdown='1'><summary>👩🏽🏫 Solution Code </summary>
|
||||
|
||||
```
|
||||
|
||||
function withdraw(uint256 amount) public returns (uint256 ethAmount, uint256 tokenAmount) {
|
||||
require(liquidity[msg.sender] >= amount, "withdraw: sender does not have enough liquidity to withdraw.");
|
||||
uint256 ethReserve = address(this).balance;
|
||||
uint256 tokenReserve = token.balanceOf(address(this));
|
||||
uint256 ethWithdrawn;
|
||||
|
||||
ethWithdrawn = amount * ethReserve / totalLiquidity;
|
||||
|
||||
uint256 tokenAmount = amount * tokenReserve / totalLiquidity;
|
||||
liquidity[msg.sender] -= amount;
|
||||
totalLiquidity -= amount;
|
||||
(bool sent, ) = payable(msg.sender).call{ value: ethWithdrawn }("");
|
||||
require(sent, "withdraw(): revert in transferring eth to you!");
|
||||
require(token.transfer(msg.sender, tokenAmount));
|
||||
emit LiquidityRemoved(msg.sender, amount, tokenAmount, ethWithdrawn);
|
||||
return (ethWithdrawn, tokenAmount);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
</details>
|
||||
|
||||
🚨 Take a second to understand what these functions are doing if you pasted them into your `DEX.sol` file in packages/hardhat/contracts:
|
||||
|
||||
### 🥅 Goals / Checks
|
||||
|
||||
- [ ] 💧 Deposit liquidity, and then check your liquidity amount through the mapping in the debug tab. Has it changed properly? Did the right amount of assets get deposited?
|
||||
|
||||
- [ ] 🧐 What happens if you `deposit()` at the beginning of the deployed contract, then another user starts swapping out for most of the balloons, and then you try to withdraw your position as a liquidity provider? Answer: you should get the amount of liquidity proportional to the ratio of assets within the isolated liquidity pool. It will not be 1:1.
|
||||
|
||||
---
|
||||
|
||||
## Checkpoint 6: UI 🖼
|
||||
|
||||
Cool beans! Your front-end should be showing something like this now!
|
||||
|
||||

|
||||
|
||||
Now, a user can just enter the amount of ETH or tokens they want to swap and the chart will display how the price is calculated. The user can also visualize how larger swaps result in more slippage and less output asset.
|
||||
|
||||
### ⚔️ Side Quests
|
||||
|
||||
- [ ] In `packages/nextjs/app/events/page.tsx` implement an event and emit for the `approve()` function to make it clear when it has been executed.
|
||||
|
||||
### ⚠️ Test it!
|
||||
|
||||
- Now is a good time to run `yarn test` to run the automated testing function. It will test that you hit the core checkpoints. You are looking for all green checkmarks and passing tests!
|
||||
|
||||
---
|
||||
|
||||
## Checkpoint 7: 💾 Deploy your contracts! 🛰
|
||||
|
||||
📡 Edit the `defaultNetwork` to [your choice of public EVM networks](https://ethereum.org/en/developers/docs/networks/) in `packages/hardhat/hardhat.config.ts`
|
||||
|
||||
🔐 You will need to generate a **deployer address** using `yarn generate` This creates a mnemonic and saves it locally.
|
||||
|
||||
👩🚀 Use `yarn account` to view your deployer account balances.
|
||||
|
||||
⛽️ You will need to send ETH to your deployer address with your wallet, or get it from a public faucet of your chosen network.
|
||||
|
||||
🚀 Run `yarn deploy` to deploy your smart contracts to a public network (selected in `hardhat.config.ts`)
|
||||
|
||||
> 💬 Hint: You can set the `defaultNetwork` in `hardhat.config.ts` to `sepolia` or `optimismSepolia` **OR** you can `yarn deploy --network sepolia` or `yarn deploy --network optimismSepolia`.
|
||||
|
||||
---
|
||||
|
||||
## Checkpoint 8: 🚢 Ship your frontend! 🚁
|
||||
|
||||
✏️ Edit your frontend config in `packages/nextjs/scaffold.config.ts` to change the `targetNetwork` to `chains.sepolia` (or `chains.optimismSepolia` if you deployed to OP Sepolia)
|
||||
|
||||
💻 View your frontend at http://localhost:3000 and verify you see the correct network.
|
||||
|
||||
📡 When you are ready to ship the frontend app...
|
||||
|
||||
📦 Run `yarn vercel` to package up your frontend and deploy.
|
||||
|
||||
> You might need to log in to Vercel first by running `yarn vercel:login`. Once you log in (email, GitHub, etc), the default options should work.
|
||||
|
||||
> If you want to redeploy to the same production URL you can run `yarn vercel --prod`. If you omit the `--prod` flag it will deploy it to a preview/test URL.
|
||||
|
||||
> Follow the steps to deploy to Vercel. It'll give you a public URL.
|
||||
|
||||
> 🦊 Since we have deployed to a public testnet, you will now need to connect using a wallet you own or use a burner wallet. By default 🔥 `burner wallets` are only available on `hardhat` . You can enable them on every chain by setting `onlyLocalBurnerWallet: false` in your frontend config (`scaffold.config.ts` in `packages/nextjs/`)
|
||||
|
||||
#### Configuration of Third-Party Services for Production-Grade Apps.
|
||||
|
||||
By default, 🏗 Scaffold-ETH 2 provides predefined API keys for popular services such as Alchemy and Etherscan. This allows you to begin developing and testing your applications more easily, avoiding the need to register for these services.
|
||||
This is great to complete your **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 9: 📜 Contract Verification
|
||||
|
||||
Run the `yarn verify --network your_network` command to verify your contracts on etherscan 🛰
|
||||
|
||||
👉 Search this address on [Sepolia Etherscan](https://sepolia.etherscan.io/) (or [Optimism Sepolia Etherscan](https://sepolia-optimism.etherscan.io/) if you deployed to OP Sepolia) to get the URL you submit to 🏃♀️[SpeedRunEthereum.com](https://speedrunethereum.com).
|
||||
|
||||
## Checkpoint 10: 💪 Flex!
|
||||
|
||||
👩❤️👨 Send some $BAL and share your public url with a friend and ask them to swap their tokens :)
|
||||
|
||||
---
|
||||
|
||||
> 🏃 Head to your next challenge [here](https://speedrunethereum.com).
|
||||
|
||||
> 💬 Problems, questions, comments on the stack? Post them to the [🏗 scaffold-eth developers chat](https://t.me/joinchat/F7nCRK3kI93PoCOk)
|
||||
|
||||
## Documentation
|
||||
|
||||
Visit our [docs](https://docs.scaffoldeth.io) to learn how to start building with Scaffold-ETH 2.
|
||||
|
||||
To know more about its features, check out our [website](https://scaffoldeth.io).
|
||||
|
||||
## Contributing to Scaffold-ETH 2
|
||||
|
||||
We welcome contributions to Scaffold-ETH 2!
|
||||
|
||||
Please see [CONTRIBUTING.MD](https://github.com/scaffold-eth/scaffold-eth-2/blob/main/CONTRIBUTING.md) for more information and guidelines for contributing to Scaffold-ETH 2.
|
||||
60
package.json
Normal file
60
package.json
Normal file
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"name": "se-2",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*"
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"account": "yarn hardhat:account",
|
||||
"account:generate": "yarn workspace @se-2/hardhat account:generate",
|
||||
"account:import": "yarn workspace @se-2/hardhat account:import",
|
||||
"account:reveal-pk": "yarn workspace @se-2/hardhat account:reveal-pk",
|
||||
"chain": "yarn hardhat:chain",
|
||||
"compile": "yarn hardhat:compile",
|
||||
"deploy": "yarn hardhat:deploy",
|
||||
"fork": "yarn hardhat:fork",
|
||||
"format": "yarn next:format && yarn hardhat:format",
|
||||
"generate": "yarn account:generate",
|
||||
"hardhat:account": "yarn workspace @se-2/hardhat account",
|
||||
"hardhat:chain": "yarn workspace @se-2/hardhat chain",
|
||||
"hardhat:check-types": "yarn workspace @se-2/hardhat check-types",
|
||||
"hardhat:clean": "yarn workspace @se-2/hardhat clean",
|
||||
"hardhat:compile": "yarn workspace @se-2/hardhat compile",
|
||||
"hardhat:deploy": "yarn workspace @se-2/hardhat deploy",
|
||||
"hardhat:flatten": "yarn workspace @se-2/hardhat flatten",
|
||||
"hardhat:fork": "yarn workspace @se-2/hardhat fork",
|
||||
"hardhat:format": "yarn workspace @se-2/hardhat format",
|
||||
"hardhat:generate": "yarn workspace @se-2/hardhat generate",
|
||||
"hardhat:hardhat-verify": "yarn workspace @se-2/hardhat hardhat-verify",
|
||||
"hardhat:lint": "yarn workspace @se-2/hardhat lint",
|
||||
"hardhat:lint-staged": "yarn workspace @se-2/hardhat lint-staged",
|
||||
"hardhat:test": "yarn workspace @se-2/hardhat test",
|
||||
"hardhat:verify": "yarn workspace @se-2/hardhat verify",
|
||||
"postinstall": "husky install",
|
||||
"ipfs": "yarn workspace @se-2/nextjs ipfs",
|
||||
"lint": "yarn next:lint && yarn hardhat:lint",
|
||||
"next:build": "yarn workspace @se-2/nextjs build",
|
||||
"next:check-types": "yarn workspace @se-2/nextjs check-types",
|
||||
"next:format": "yarn workspace @se-2/nextjs format",
|
||||
"next:lint": "yarn workspace @se-2/nextjs lint",
|
||||
"next:serve": "yarn workspace @se-2/nextjs serve",
|
||||
"precommit": "lint-staged",
|
||||
"start": "yarn workspace @se-2/nextjs dev",
|
||||
"test": "yarn hardhat:test",
|
||||
"vercel": "yarn workspace @se-2/nextjs vercel",
|
||||
"vercel:login": "yarn workspace @se-2/nextjs vercel:login",
|
||||
"vercel:yolo": "yarn workspace @se-2/nextjs vercel:yolo",
|
||||
"verify": "yarn hardhat:verify"
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "~9.1.6",
|
||||
"lint-staged": "~13.2.2"
|
||||
},
|
||||
"packageManager": "yarn@3.2.3",
|
||||
"engines": {
|
||||
"node": ">=20.18.3"
|
||||
}
|
||||
}
|
||||
13
packages/hardhat/.env.example
Normal file
13
packages/hardhat/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# Template for Hardhat environment variables.
|
||||
|
||||
# To use this template, copy this file, rename it .env, and fill in the values.
|
||||
|
||||
# If not set, we provide default values (check `hardhat.config.ts`) so developers can start prototyping out of the box,
|
||||
# but we recommend getting your own API Keys for Production Apps.
|
||||
|
||||
# To access the values stored in this .env file you can use: process.env.VARIABLENAME
|
||||
ALCHEMY_API_KEY=
|
||||
ETHERSCAN_V2_API_KEY=
|
||||
|
||||
# Don't fill this value manually, run yarn generate to generate a new account or yarn account:import to import an existing PK.
|
||||
DEPLOYER_PRIVATE_KEY_ENCRYPTED=
|
||||
30
packages/hardhat/.gitignore
vendored
Normal file
30
packages/hardhat/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# env files
|
||||
.env
|
||||
|
||||
# coverage
|
||||
coverage
|
||||
coverage.json
|
||||
|
||||
# typechain
|
||||
typechain
|
||||
typechain-types
|
||||
|
||||
# hardhat files
|
||||
cache
|
||||
artifacts
|
||||
|
||||
# zkSync files
|
||||
artifacts-zk
|
||||
cache-zk
|
||||
|
||||
# deployments
|
||||
deployments/localhost
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# other
|
||||
temp
|
||||
18
packages/hardhat/.prettierrc.json
Normal file
18
packages/hardhat/.prettierrc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-solidity"],
|
||||
"arrowParens": "avoid",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.sol",
|
||||
"options": {
|
||||
"printWidth": 120,
|
||||
"tabWidth": 4,
|
||||
"singleQuote": false,
|
||||
"bracketSpacing": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
10
packages/hardhat/contracts/Balloons.sol
Normal file
10
packages/hardhat/contracts/Balloons.sol
Normal file
@@ -0,0 +1,10 @@
|
||||
//SPDX-License-Identifier: MIT
|
||||
pragma solidity >=0.8.0 <0.9.0;
|
||||
|
||||
import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
|
||||
|
||||
contract Balloons is ERC20 {
|
||||
constructor() ERC20("Balloons", "BAL") {
|
||||
_mint(msg.sender, 1000 ether); // mints 1000 balloons!
|
||||
}
|
||||
}
|
||||
99
packages/hardhat/contracts/DEX.sol
Normal file
99
packages/hardhat/contracts/DEX.sol
Normal file
@@ -0,0 +1,99 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
pragma solidity >=0.8.0 <0.9.0;
|
||||
|
||||
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
|
||||
|
||||
/**
|
||||
* @title DEX Template
|
||||
* @author stevepham.eth and m00npapi.eth
|
||||
* @notice Empty DEX.sol that just outlines what features could be part of the challenge (up to you!)
|
||||
* @dev We want to create an automatic market where our contract will hold reserves of both ETH and 🎈 Balloons. These reserves will provide liquidity that allows anyone to swap between the assets.
|
||||
* NOTE: functions outlined here are what work with the front end of this challenge. Also return variable names need to be specified exactly may be referenced (It may be helpful to cross reference with front-end code function calls).
|
||||
*/
|
||||
contract DEX {
|
||||
/* ========== GLOBAL VARIABLES ========== */
|
||||
|
||||
IERC20 token; //instantiates the imported contract
|
||||
|
||||
/* ========== EVENTS ========== */
|
||||
|
||||
/**
|
||||
* @notice Emitted when ethToToken() swap transacted
|
||||
*/
|
||||
event EthToTokenSwap(address swapper, uint256 tokenOutput, uint256 ethInput);
|
||||
|
||||
/**
|
||||
* @notice Emitted when tokenToEth() swap transacted
|
||||
*/
|
||||
event TokenToEthSwap(address swapper, uint256 tokensInput, uint256 ethOutput);
|
||||
|
||||
/**
|
||||
* @notice Emitted when liquidity provided to DEX and mints LPTs.
|
||||
*/
|
||||
event LiquidityProvided(address liquidityProvider, uint256 liquidityMinted, uint256 ethInput, uint256 tokensInput);
|
||||
|
||||
/**
|
||||
* @notice Emitted when liquidity removed from DEX and decreases LPT count within DEX.
|
||||
*/
|
||||
event LiquidityRemoved(
|
||||
address liquidityRemover,
|
||||
uint256 liquidityWithdrawn,
|
||||
uint256 tokensOutput,
|
||||
uint256 ethOutput
|
||||
);
|
||||
|
||||
/* ========== CONSTRUCTOR ========== */
|
||||
|
||||
constructor(address tokenAddr) {
|
||||
token = IERC20(tokenAddr); //specifies the token address that will hook into the interface and be used through the variable 'token'
|
||||
}
|
||||
|
||||
/* ========== MUTATIVE FUNCTIONS ========== */
|
||||
|
||||
/**
|
||||
* @notice initializes amount of tokens that will be transferred to the DEX itself from the erc20 contract mintee (and only them based on how Balloons.sol is written). Loads contract up with both ETH and Balloons.
|
||||
* @param tokens amount to be transferred to DEX
|
||||
* @return totalLiquidity is the number of LPTs minting as a result of deposits made to DEX contract
|
||||
* NOTE: since ratio is 1:1, this is fine to initialize the totalLiquidity (wrt to balloons) as equal to eth balance of contract.
|
||||
*/
|
||||
function init(uint256 tokens) public payable returns (uint256) {}
|
||||
|
||||
/**
|
||||
* @notice returns yOutput, or yDelta for xInput (or xDelta)
|
||||
* @dev Follow along with the [original tutorial](https://medium.com/@austin_48503/%EF%B8%8F-minimum-viable-exchange-d84f30bd0c90) Price section for an understanding of the DEX's pricing model and for a price function to add to your contract. You may need to update the Solidity syntax (e.g. use + instead of .add, * instead of .mul, etc). Deploy when you are done.
|
||||
*/
|
||||
function price(uint256 xInput, uint256 xReserves, uint256 yReserves) public pure returns (uint256 yOutput) {}
|
||||
|
||||
/**
|
||||
* @notice returns liquidity for a user.
|
||||
* NOTE: this is not needed typically due to the `liquidity()` mapping variable being public and having a getter as a result. This is left though as it is used within the front end code (App.jsx).
|
||||
* NOTE: if you are using a mapping liquidity, then you can use `return liquidity[lp]` to get the liquidity for a user.
|
||||
* NOTE: if you will be submitting the challenge make sure to implement this function as it is used in the tests.
|
||||
*/
|
||||
function getLiquidity(address lp) public view returns (uint256) {}
|
||||
|
||||
/**
|
||||
* @notice sends Ether to DEX in exchange for $BAL
|
||||
*/
|
||||
function ethToToken() public payable returns (uint256 tokenOutput) {}
|
||||
|
||||
/**
|
||||
* @notice sends $BAL tokens to DEX in exchange for Ether
|
||||
*/
|
||||
function tokenToEth(uint256 tokenInput) public returns (uint256 ethOutput) {}
|
||||
|
||||
/**
|
||||
* @notice allows deposits of $BAL and $ETH to liquidity pool
|
||||
* NOTE: parameter is the msg.value sent with this function call. That amount is used to determine the amount of $BAL needed as well and taken from the depositor.
|
||||
* NOTE: user has to make sure to give DEX approval to spend their tokens on their behalf by calling approve function prior to this function call.
|
||||
* NOTE: Equal parts of both assets will be removed from the user's wallet with respect to the price outlined by the AMM.
|
||||
*/
|
||||
function deposit() public payable returns (uint256 tokensDeposited) {}
|
||||
|
||||
/**
|
||||
* @notice allows withdrawal of $BAL and $ETH from liquidity pool
|
||||
* NOTE: with this current code, the msg caller could end up getting very little back if the liquidity is super low in the pool. I guess they could see that with the UI.
|
||||
*/
|
||||
function withdraw(uint256 amount) public returns (uint256 ethAmount, uint256 tokenAmount) {}
|
||||
}
|
||||
72
packages/hardhat/deploy/00_deploy_dex.ts
Normal file
72
packages/hardhat/deploy/00_deploy_dex.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { HardhatRuntimeEnvironment } from "hardhat/types";
|
||||
import { DeployFunction } from "hardhat-deploy/types";
|
||||
import { DEX } from "../typechain-types/contracts/DEX";
|
||||
import { Balloons } from "../typechain-types/contracts/Balloons";
|
||||
|
||||
/**
|
||||
* Deploys a contract named "YourContract" using the deployer account and
|
||||
* constructor arguments set to the deployer address
|
||||
*
|
||||
* @param hre HardhatRuntimeEnvironment object.
|
||||
*/
|
||||
const deployYourContract: 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("Balloons", {
|
||||
from: deployer,
|
||||
// Contract constructor arguments
|
||||
//args: [deployer],
|
||||
log: true,
|
||||
// autoMine: can be passed to the deploy function to make the deployment process faster on local networks by
|
||||
// automatically mining the contract deployment transaction. There is no effect on live networks.
|
||||
autoMine: true,
|
||||
});
|
||||
// Get the deployed contract
|
||||
// const yourContract = await hre.ethers.getContract("YourContract", deployer);
|
||||
const balloons: Balloons = await hre.ethers.getContract("Balloons", deployer);
|
||||
const balloonsAddress = await balloons.getAddress();
|
||||
|
||||
await deploy("DEX", {
|
||||
from: deployer,
|
||||
// Contract constructor arguments
|
||||
args: [balloonsAddress],
|
||||
log: true,
|
||||
// autoMine: can be passed to the deploy function to make the deployment process faster on local networks by
|
||||
// automatically mining the contract deployment transaction. There is no effect on live networks.
|
||||
autoMine: true,
|
||||
});
|
||||
|
||||
const dex = (await hre.ethers.getContract("DEX", deployer)) as DEX;
|
||||
|
||||
// // paste in your front-end address here to get 10 balloons on deploy:
|
||||
// await balloons.transfer("YOUR_FRONTEND_ADDRESS", "" + 10 * 10 ** 18);
|
||||
|
||||
// // uncomment to init DEX on deploy:
|
||||
|
||||
// const dexAddress = await dex.getAddress();
|
||||
// console.log("Approving DEX (" + dexAddress + ") to take Balloons from main account...");
|
||||
// // If you are going to the testnet make sure your deployer account has enough ETH
|
||||
// await balloons.approve(dexAddress, hre.ethers.parseEther("100"));
|
||||
// console.log("INIT exchange...");
|
||||
// await dex.init(hre.ethers.parseEther("5"), {
|
||||
// value: hre.ethers.parseEther("5"),
|
||||
// gasLimit: 200000,
|
||||
// });
|
||||
};
|
||||
|
||||
export default deployYourContract;
|
||||
|
||||
// Tags are useful if you have multiple deploy files and only want to run one of them.
|
||||
// e.g. yarn deploy --tags YourContract
|
||||
deployYourContract.tags = ["Balloons", "DEX"];
|
||||
44
packages/hardhat/eslint.config.mjs
Normal file
44
packages/hardhat/eslint.config.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import globals from "globals";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import prettierPlugin from "eslint-plugin-prettier";
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["**/artifacts", "**/cache", "**/contracts", "**/node_modules/", "**/typechain-types", "**/*.json"]),
|
||||
{
|
||||
extends: compat.extends("plugin:@typescript-eslint/recommended", "prettier"),
|
||||
|
||||
plugins: {
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
{
|
||||
endOfLine: "auto",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
152
packages/hardhat/hardhat.config.ts
Normal file
152
packages/hardhat/hardhat.config.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { HardhatUserConfig } from "hardhat/config";
|
||||
import "@nomicfoundation/hardhat-ethers";
|
||||
import "@nomicfoundation/hardhat-chai-matchers";
|
||||
import "@typechain/hardhat";
|
||||
import "hardhat-gas-reporter";
|
||||
import "solidity-coverage";
|
||||
import "@nomicfoundation/hardhat-verify";
|
||||
import "hardhat-deploy";
|
||||
import "hardhat-deploy-ethers";
|
||||
import { task } from "hardhat/config";
|
||||
import generateTsAbis from "./scripts/generateTsAbis";
|
||||
|
||||
// If not set, it uses ours Alchemy's default API key.
|
||||
// You can get your own at https://dashboard.alchemyapi.io
|
||||
const providerApiKey = process.env.ALCHEMY_API_KEY || "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],
|
||||
},
|
||||
celoAlfajores: {
|
||||
url: "https://alfajores-forno.celo-testnet.org",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
},
|
||||
// Configuration for harhdat-verify plugin
|
||||
etherscan: {
|
||||
apiKey: etherscanApiKey,
|
||||
},
|
||||
// Configuration for etherscan-verify from hardhat-deploy plugin
|
||||
verify: {
|
||||
etherscan: {
|
||||
apiKey: etherscanApiKey,
|
||||
},
|
||||
},
|
||||
sourcify: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Extend the deploy task
|
||||
task("deploy").setAction(async (args, hre, runSuper) => {
|
||||
// Run the original deploy task
|
||||
await runSuper(args);
|
||||
// Force run the generateTsAbis script
|
||||
await generateTsAbis(hre);
|
||||
});
|
||||
|
||||
export default config;
|
||||
63
packages/hardhat/package.json
Normal file
63
packages/hardhat/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@se-2/hardhat",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"account": "hardhat run scripts/listAccount.ts",
|
||||
"account:generate": "hardhat run scripts/generateAccount.ts",
|
||||
"account:import": "hardhat run scripts/importAccount.ts",
|
||||
"account:reveal-pk": "hardhat run scripts/revealPK.ts",
|
||||
"chain": "hardhat node --network hardhat --no-deploy",
|
||||
"check-types": "tsc --noEmit --incremental",
|
||||
"clean": "hardhat clean",
|
||||
"compile": "hardhat compile",
|
||||
"deploy": "ts-node scripts/runHardhatDeployWithPK.ts",
|
||||
"flatten": "hardhat flatten",
|
||||
"fork": "MAINNET_FORKING_ENABLED=true hardhat node --network hardhat --no-deploy",
|
||||
"format": "prettier --write './**/*.(ts|sol)'",
|
||||
"generate": "yarn account:generate",
|
||||
"hardhat-verify": "hardhat verify",
|
||||
"lint": "eslint",
|
||||
"lint-staged": "eslint",
|
||||
"test": "REPORT_GAS=true hardhat test --network hardhat",
|
||||
"verify": "hardhat etherscan-verify"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/password": "^4.0.2",
|
||||
"@openzeppelin/contracts": "~5.0.2",
|
||||
"@typechain/ethers-v6": "~0.5.1",
|
||||
"dotenv": "~16.4.5",
|
||||
"envfile": "~7.1.0",
|
||||
"qrcode": "~1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ethersproject/abi": "~5.7.0",
|
||||
"@ethersproject/providers": "~5.7.2",
|
||||
"@nomicfoundation/hardhat-chai-matchers": "~2.0.7",
|
||||
"@nomicfoundation/hardhat-ethers": "~3.0.8",
|
||||
"@nomicfoundation/hardhat-network-helpers": "~1.0.11",
|
||||
"@nomicfoundation/hardhat-verify": "~2.0.10",
|
||||
"@typechain/ethers-v5": "~11.1.2",
|
||||
"@typechain/hardhat": "~9.1.0",
|
||||
"@types/eslint": "~9.6.1",
|
||||
"@types/mocha": "~10.0.10",
|
||||
"@types/prettier": "~3.0.0",
|
||||
"@types/qrcode": "~1.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "~8.27.0",
|
||||
"@typescript-eslint/parser": "~8.27.0",
|
||||
"chai": "~4.5.0",
|
||||
"eslint": "~9.23.0",
|
||||
"eslint-config-prettier": "~10.1.1",
|
||||
"eslint-plugin-prettier": "~5.2.4",
|
||||
"ethers": "~6.13.2",
|
||||
"hardhat": "~2.22.10",
|
||||
"hardhat-deploy": "^1.0.4",
|
||||
"hardhat-deploy-ethers": "~0.4.2",
|
||||
"hardhat-gas-reporter": "~2.2.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-solidity": "~1.4.1",
|
||||
"solidity-coverage": "~0.8.13",
|
||||
"ts-node": "~10.9.1",
|
||||
"typechain": "~8.3.2",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
58
packages/hardhat/scripts/generateAccount.ts
Normal file
58
packages/hardhat/scripts/generateAccount.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ethers } from "ethers";
|
||||
import { parse, stringify } from "envfile";
|
||||
import * as fs from "fs";
|
||||
import password from "@inquirer/password";
|
||||
|
||||
const envFilePath = "./.env";
|
||||
|
||||
const getValidatedPassword = async () => {
|
||||
while (true) {
|
||||
const pass = await password({ message: "Enter a password to encrypt your private key:" });
|
||||
const confirmation = await password({ message: "Confirm password:" });
|
||||
|
||||
if (pass === confirmation) {
|
||||
return pass;
|
||||
}
|
||||
console.log("❌ Passwords don't match. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const setNewEnvConfig = async (existingEnvConfig = {}) => {
|
||||
console.log("👛 Generating new Wallet\n");
|
||||
const randomWallet = ethers.Wallet.createRandom();
|
||||
|
||||
const pass = await getValidatedPassword();
|
||||
const encryptedJson = await randomWallet.encrypt(pass);
|
||||
|
||||
const newEnvConfig = {
|
||||
...existingEnvConfig,
|
||||
DEPLOYER_PRIVATE_KEY_ENCRYPTED: encryptedJson,
|
||||
};
|
||||
|
||||
// Store in .env
|
||||
fs.writeFileSync(envFilePath, stringify(newEnvConfig));
|
||||
console.log("\n📄 Encrypted Private Key saved to packages/hardhat/.env file");
|
||||
console.log("🪄 Generated wallet address:", randomWallet.address, "\n");
|
||||
console.log("⚠️ Make sure to remember your password! You'll need it to decrypt the private key.");
|
||||
};
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(envFilePath)) {
|
||||
// No .env file yet.
|
||||
await setNewEnvConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingEnvConfig = parse(fs.readFileSync(envFilePath).toString());
|
||||
if (existingEnvConfig.DEPLOYER_PRIVATE_KEY_ENCRYPTED) {
|
||||
console.log("⚠️ You already have a deployer account. Check the packages/hardhat/.env file");
|
||||
return;
|
||||
}
|
||||
|
||||
await setNewEnvConfig(existingEnvConfig);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
127
packages/hardhat/scripts/generateTsAbis.ts
Normal file
127
packages/hardhat/scripts/generateTsAbis.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* 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>;
|
||||
for (const chainName of getDirectories(DEPLOYMENTS_DIR)) {
|
||||
const chainId = fs.readFileSync(`${DEPLOYMENTS_DIR}/${chainName}/.chainId`).toString();
|
||||
const contracts = {} as Record<string, any>;
|
||||
for (const contractName of getContractNames(`${DEPLOYMENTS_DIR}/${chainName}`)) {
|
||||
const { abi, address, metadata, receipt } = JSON.parse(
|
||||
fs.readFileSync(`${DEPLOYMENTS_DIR}/${chainName}/${contractName}.json`).toString(),
|
||||
);
|
||||
const inheritedFunctions = metadata ? getInheritedFunctions(JSON.parse(metadata).sources, contractName) : {};
|
||||
contracts[contractName] = { address, abi, inheritedFunctions, deployedOnBlock: receipt.blockNumber };
|
||||
}
|
||||
output[chainId] = contracts;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the TypeScript contract definition file based on the json output of the contract deployment scripts
|
||||
* This script should be run last.
|
||||
*/
|
||||
const generateTsAbis: DeployFunction = async function () {
|
||||
const TARGET_DIR = "../nextjs/contracts/";
|
||||
const allContractsData = getContractDataFromDeployments();
|
||||
|
||||
const fileContent = Object.entries(allContractsData).reduce((content, [chainId, chainConfig]) => {
|
||||
return `${content}${parseInt(chainId).toFixed(0)}:${JSON.stringify(chainConfig, null, 2)},`;
|
||||
}, "");
|
||||
|
||||
if (!fs.existsSync(TARGET_DIR)) {
|
||||
fs.mkdirSync(TARGET_DIR);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
`${TARGET_DIR}deployedContracts.ts`,
|
||||
await prettier.format(
|
||||
`${generatedContractComment} import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract"; \n\n
|
||||
const deployedContracts = {${fileContent}} as const; \n\n export default deployedContracts satisfies GenericContractsDeclaration`,
|
||||
{
|
||||
parser: "typescript",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
console.log(`📝 Updated TypeScript contract definition file on ${TARGET_DIR}deployedContracts.ts`);
|
||||
};
|
||||
|
||||
export default generateTsAbis;
|
||||
72
packages/hardhat/scripts/importAccount.ts
Normal file
72
packages/hardhat/scripts/importAccount.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ethers } from "ethers";
|
||||
import { parse, stringify } from "envfile";
|
||||
import * as fs from "fs";
|
||||
import password from "@inquirer/password";
|
||||
|
||||
const envFilePath = "./.env";
|
||||
|
||||
const getValidatedPassword = async () => {
|
||||
while (true) {
|
||||
const pass = await password({ message: "Enter a password to encrypt your private key:" });
|
||||
const confirmation = await password({ message: "Confirm password:" });
|
||||
|
||||
if (pass === confirmation) {
|
||||
return pass;
|
||||
}
|
||||
console.log("❌ Passwords don't match. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const getWalletFromPrivateKey = async () => {
|
||||
while (true) {
|
||||
const privateKey = await password({ message: "Paste your private key:" });
|
||||
try {
|
||||
const wallet = new ethers.Wallet(privateKey);
|
||||
return wallet;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.log("❌ Invalid private key format. Please try again.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setNewEnvConfig = async (existingEnvConfig = {}) => {
|
||||
console.log("👛 Importing Wallet\n");
|
||||
|
||||
const wallet = await getWalletFromPrivateKey();
|
||||
|
||||
const pass = await getValidatedPassword();
|
||||
const encryptedJson = await wallet.encrypt(pass);
|
||||
|
||||
const newEnvConfig = {
|
||||
...existingEnvConfig,
|
||||
DEPLOYER_PRIVATE_KEY_ENCRYPTED: encryptedJson,
|
||||
};
|
||||
|
||||
// Store in .env
|
||||
fs.writeFileSync(envFilePath, stringify(newEnvConfig));
|
||||
console.log("\n📄 Encrypted Private Key saved to packages/hardhat/.env file");
|
||||
console.log("🪄 Imported wallet address:", wallet.address, "\n");
|
||||
console.log("⚠️ Make sure to remember your password! You'll need it to decrypt the private key.");
|
||||
};
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(envFilePath)) {
|
||||
// No .env file yet.
|
||||
await setNewEnvConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingEnvConfig = parse(fs.readFileSync(envFilePath).toString());
|
||||
if (existingEnvConfig.DEPLOYER_PRIVATE_KEY_ENCRYPTED) {
|
||||
console.log("⚠️ You already have a deployer account. Check the packages/hardhat/.env file");
|
||||
return;
|
||||
}
|
||||
|
||||
await setNewEnvConfig(existingEnvConfig);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
52
packages/hardhat/scripts/listAccount.ts
Normal file
52
packages/hardhat/scripts/listAccount.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { ethers, Wallet } from "ethers";
|
||||
import QRCode from "qrcode";
|
||||
import { config } from "hardhat";
|
||||
import password from "@inquirer/password";
|
||||
|
||||
async function main() {
|
||||
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
|
||||
|
||||
if (!encryptedKey) {
|
||||
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
|
||||
return;
|
||||
}
|
||||
|
||||
const pass = await password({ message: "Enter your password to decrypt the private key:" });
|
||||
let wallet: Wallet;
|
||||
try {
|
||||
wallet = (await Wallet.fromEncryptedJson(encryptedKey, pass)) as Wallet;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.log("❌ Failed to decrypt private key. Wrong password?");
|
||||
return;
|
||||
}
|
||||
|
||||
const address = wallet.address;
|
||||
console.log(await QRCode.toString(address, { type: "terminal", small: true }));
|
||||
console.log("Public address:", address, "\n");
|
||||
|
||||
// Balance on each network
|
||||
const availableNetworks = config.networks;
|
||||
for (const networkName in availableNetworks) {
|
||||
try {
|
||||
const network = availableNetworks[networkName];
|
||||
if (!("url" in network)) continue;
|
||||
const provider = new ethers.JsonRpcProvider(network.url);
|
||||
await provider._detectNetwork();
|
||||
const balance = await provider.getBalance(address);
|
||||
console.log("--", networkName, "-- 📡");
|
||||
console.log(" balance:", +ethers.formatEther(balance));
|
||||
console.log(" nonce:", +(await provider.getTransactionCount(address)));
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.log("Can't connect to network", networkName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
31
packages/hardhat/scripts/revealPK.ts
Normal file
31
packages/hardhat/scripts/revealPK.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { Wallet } from "ethers";
|
||||
import password from "@inquirer/password";
|
||||
|
||||
async function main() {
|
||||
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
|
||||
|
||||
if (!encryptedKey) {
|
||||
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("👀 This will reveal your private key on the console.\n");
|
||||
|
||||
const pass = await password({ message: "Enter your password to decrypt the private key:" });
|
||||
let wallet: Wallet;
|
||||
try {
|
||||
wallet = (await Wallet.fromEncryptedJson(encryptedKey, pass)) as Wallet;
|
||||
} catch {
|
||||
console.log("❌ Failed to decrypt private key. Wrong password?");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\n🔑 Private key:", wallet.privateKey);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
58
packages/hardhat/scripts/runHardhatDeployWithPK.ts
Normal file
58
packages/hardhat/scripts/runHardhatDeployWithPK.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { Wallet } from "ethers";
|
||||
import password from "@inquirer/password";
|
||||
import { spawn } from "child_process";
|
||||
import { config } from "hardhat";
|
||||
|
||||
/**
|
||||
* Unencrypts the private key and runs the hardhat deploy command
|
||||
*/
|
||||
async function main() {
|
||||
const networkIndex = process.argv.indexOf("--network");
|
||||
const networkName = networkIndex !== -1 ? process.argv[networkIndex + 1] : config.defaultNetwork;
|
||||
|
||||
if (networkName === "localhost" || networkName === "hardhat") {
|
||||
// Deploy command on the localhost network
|
||||
const hardhat = spawn("hardhat", ["deploy", ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
hardhat.on("exit", code => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
|
||||
|
||||
if (!encryptedKey) {
|
||||
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
|
||||
return;
|
||||
}
|
||||
|
||||
const pass = await password({ message: "Enter password to decrypt private key:" });
|
||||
|
||||
try {
|
||||
const wallet = await Wallet.fromEncryptedJson(encryptedKey, pass);
|
||||
process.env.__RUNTIME_DEPLOYER_PRIVATE_KEY = wallet.privateKey;
|
||||
|
||||
const hardhat = spawn("hardhat", ["deploy", ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
hardhat.on("exit", code => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.error("Failed to decrypt private key. Wrong password?");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
2
packages/hardhat/test/.gitkeep
Normal file
2
packages/hardhat/test/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# Write tests for your smart contract in this directory
|
||||
# Example: YourContract.ts
|
||||
386
packages/hardhat/test/DEX.ts
Normal file
386
packages/hardhat/test/DEX.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { ethers } from "hardhat";
|
||||
import { expect } from "chai";
|
||||
import { ContractTransactionReceipt } from "ethers";
|
||||
import { anyValue } from "@nomicfoundation/hardhat-chai-matchers/withArgs";
|
||||
import { HardhatEthersSigner } from "@nomicfoundation/hardhat-ethers/signers";
|
||||
import { Balloons, DEX } from "../typechain-types";
|
||||
|
||||
describe("🚩 Challenge: ⚖️ 🪙 DEX", () => {
|
||||
// this.timeout(45000);
|
||||
|
||||
let dexContract: DEX;
|
||||
let balloonsContract: Balloons;
|
||||
let deployer: HardhatEthersSigner;
|
||||
let user2: HardhatEthersSigner;
|
||||
let user3: HardhatEthersSigner;
|
||||
|
||||
const contractAddress = process.env.CONTRACT_ADDRESS;
|
||||
let contractArtifact: string;
|
||||
if (contractAddress) {
|
||||
contractArtifact = `contracts/download-${contractAddress}.sol:DEX`;
|
||||
} else {
|
||||
contractArtifact = "contracts/DEX.sol:DEX";
|
||||
}
|
||||
|
||||
async function getEventValue(txReceipt: ContractTransactionReceipt | null, eventNumber: number) {
|
||||
if (!txReceipt) return;
|
||||
const dexContractAddress = await dexContract.getAddress();
|
||||
const log = txReceipt.logs.find(log => log.address === dexContractAddress);
|
||||
const logDescr = log && dexContract.interface.parseLog(log);
|
||||
const args = logDescr?.args;
|
||||
return args && args[eventNumber]; // index of ethAmount in event
|
||||
}
|
||||
|
||||
async function deployNewInstance() {
|
||||
before("Deploying fresh contracts", async function () {
|
||||
console.log("\t", " 🛫 Deploying new contracts...");
|
||||
|
||||
const BalloonsContract = await ethers.getContractFactory("Balloons");
|
||||
balloonsContract = await BalloonsContract.deploy();
|
||||
const balloonsAddress = await balloonsContract.getAddress();
|
||||
|
||||
const DexContract = await ethers.getContractFactory(contractArtifact);
|
||||
dexContract = (await DexContract.deploy(balloonsAddress)) as DEX;
|
||||
const dexContractAddress = await dexContract.getAddress();
|
||||
|
||||
await balloonsContract.approve(dexContractAddress, ethers.parseEther("100"));
|
||||
|
||||
await dexContract.init(ethers.parseEther("5"), {
|
||||
value: ethers.parseEther("5"),
|
||||
gasLimit: 200000,
|
||||
});
|
||||
|
||||
[deployer, user2, user3] = await ethers.getSigners();
|
||||
|
||||
await balloonsContract.transfer(user2.address, ethers.parseEther("10"));
|
||||
await balloonsContract.transfer(user3.address, ethers.parseEther("10"));
|
||||
});
|
||||
}
|
||||
|
||||
// quick fix to let gas reporter fetch data from gas station & coinmarketcap
|
||||
before(done => {
|
||||
setTimeout(done, 2000);
|
||||
});
|
||||
|
||||
// --------------------- START OF CHECKPOINT 2 ---------------------
|
||||
describe("Checkpoint 2: Reserves", function () {
|
||||
describe("Deploying the contracts and testing the init function", () => {
|
||||
it("Should deploy contracts", async function () {
|
||||
const BalloonsContract = await ethers.getContractFactory("Balloons");
|
||||
balloonsContract = await BalloonsContract.deploy();
|
||||
const balloonsAddress = await balloonsContract.getAddress();
|
||||
|
||||
const DexContract = await ethers.getContractFactory(contractArtifact);
|
||||
dexContract = (await DexContract.deploy(balloonsAddress)) as DEX;
|
||||
const dexContractAddress = await dexContract.getAddress();
|
||||
|
||||
await balloonsContract.approve(dexContractAddress, ethers.parseEther("100"));
|
||||
|
||||
const lpBefore = await dexContract.totalLiquidity();
|
||||
console.log(
|
||||
"\t",
|
||||
" 💦 Expecting total liquidity to be 0 before initializing. Total liquidity:",
|
||||
ethers.formatEther(lpBefore),
|
||||
);
|
||||
expect(lpBefore).to.equal(0);
|
||||
console.log("\t", " 🔰 Calling init with 5 Eth");
|
||||
await dexContract.init(ethers.parseEther("5"), {
|
||||
value: ethers.parseEther("5"),
|
||||
gasLimit: 200000,
|
||||
});
|
||||
const lpAfter = await dexContract.totalLiquidity();
|
||||
console.log("\t", " 💦 Expecting new total liquidity to be 5. Total liquidity:", ethers.formatEther(lpAfter));
|
||||
});
|
||||
});
|
||||
});
|
||||
// ----------------- END OF CHECKPOINT 2 -----------------
|
||||
// ----------------- START OF CHECKPOINT 3 -----------------
|
||||
describe("Checkpoint 3: Price", async () => {
|
||||
describe("price()", async () => {
|
||||
// https://etherscan.io/address/0x7a250d5630b4cf539739df2c5dacb4c659f2488d#readContract
|
||||
// in Uniswap the fee is build in getAmountOut() function
|
||||
it("Should calculate the price correctly", async function () {
|
||||
let xInput = ethers.parseEther("1");
|
||||
let xReserves = ethers.parseEther("5");
|
||||
let yReserves = ethers.parseEther("5");
|
||||
let yOutput = await dexContract.price(xInput, xReserves, yReserves);
|
||||
expect(
|
||||
yOutput.toString(),
|
||||
"Check your price function's calculations. Don't forget the 3% fee for liquidity providers, and the function should be view or pure.",
|
||||
).to.equal("831248957812239453");
|
||||
xInput = ethers.parseEther("1");
|
||||
xReserves = ethers.parseEther("10");
|
||||
yReserves = ethers.parseEther("15");
|
||||
yOutput = await dexContract.price(xInput, xReserves, yReserves);
|
||||
expect(yOutput.toString()).to.equal("1359916340820223697");
|
||||
});
|
||||
});
|
||||
});
|
||||
// ----------------- END OF CHECKPOINT 3 -----------------
|
||||
// ----------------- START OF CHECKPOINT 4 -----------------
|
||||
describe("Checkpoint 4: Trading", function () {
|
||||
deployNewInstance();
|
||||
describe("ethToToken()", function () {
|
||||
it("Should be able to send 1 Ether to DEX in exchange for _ $BAL", async function () {
|
||||
const dexContractAddress = await dexContract.getAddress();
|
||||
|
||||
const dex_eth_start = await ethers.provider.getBalance(dexContractAddress);
|
||||
console.log("\t", " 💵 Dex contract's initial Eth balance:", ethers.formatEther(dex_eth_start));
|
||||
console.log("\t", " 📞 Calling ethToToken with a value of 1 Eth...");
|
||||
const tx1 = await dexContract.ethToToken({
|
||||
value: ethers.parseEther("1"),
|
||||
});
|
||||
|
||||
expect(tx1, "ethToToken should revert before initalization").not.to.be.reverted;
|
||||
|
||||
console.log("\t", " 🔰 Initializing...");
|
||||
const tx1_receipt = await tx1.wait();
|
||||
const ethSent_1 = await getEventValue(tx1_receipt, 2);
|
||||
|
||||
console.log("\t", " 🔼 Expecting the Eth value emitted to be 1. Value:", ethers.formatEther(ethSent_1));
|
||||
expect(ethSent_1, "Check you are emitting the correct Eth value and in the correct order").to.equal(
|
||||
ethers.parseEther("1"),
|
||||
);
|
||||
|
||||
const dex_eth_after = await ethers.provider.getBalance(dexContractAddress);
|
||||
console.log("\t", " 💵 Dex contract's new Eth balance:", ethers.formatEther(dex_eth_after));
|
||||
|
||||
console.log("\t", " 💵 Expecting final Dex balance to have increased by 1...");
|
||||
expect(await ethers.provider.getBalance(dexContractAddress)).to.equal(ethers.parseEther("6"));
|
||||
});
|
||||
|
||||
it("Should revert if 0 ETH sent", async function () {
|
||||
await expect(
|
||||
dexContract.ethToToken({ value: ethers.parseEther("0") }),
|
||||
"ethToToken should revert when sending 0 value...",
|
||||
).to.be.reverted;
|
||||
});
|
||||
|
||||
it("Should send less tokens after the first trade (ethToToken called)", async function () {
|
||||
const user2BalBefore = await balloonsContract.balanceOf(user2.address);
|
||||
console.log("\t", " 💵 User2 initial $BAL balance:", ethers.formatEther(user2BalBefore));
|
||||
console.log("\t", " 🥈 User2 calling ethToToken with value of 1 ETH...");
|
||||
await dexContract.connect(user2).ethToToken({ value: ethers.parseEther("1") });
|
||||
|
||||
const user2BalAfter = await balloonsContract.balanceOf(user2.address);
|
||||
console.log("\t", " 💵 User2 new $BAL balance:", ethers.formatEther(user2BalAfter));
|
||||
|
||||
const user3BalBefore = await balloonsContract.balanceOf(user3.address);
|
||||
console.log("\t", " 💵 User3 initial $BAL balance:", ethers.formatEther(user3BalBefore));
|
||||
console.log("\t", " 🥉 User3 calling ethToToken with value of 1 ETH...");
|
||||
await dexContract.connect(user3).ethToToken({ value: ethers.parseEther("1") });
|
||||
|
||||
const user3BalAfter = await balloonsContract.balanceOf(user3.address);
|
||||
console.log("\t", " 💵 User3 new $BAL balance:", ethers.formatEther(user3BalAfter));
|
||||
|
||||
console.log("\t", " 💵 Expecting User2 to have aquired more $BAL than User3...");
|
||||
expect(user2BalAfter).to.greaterThan(user3BalAfter);
|
||||
});
|
||||
|
||||
it("Should emit an event when ethToToken() called", async function () {
|
||||
await expect(
|
||||
dexContract.ethToToken({ value: ethers.parseEther("1") }),
|
||||
"Make sure you're emitting the EthToTokenSwap event correctly",
|
||||
).to.emit(dexContract, "EthToTokenSwap");
|
||||
});
|
||||
|
||||
it("Should transfer tokens to purchaser after trade", async function () {
|
||||
const user3_token_before = await balloonsContract.balanceOf(user3.address);
|
||||
console.log("\t", " 💵 User3 initial $BAL balance:", ethers.formatEther(user3_token_before));
|
||||
|
||||
console.log("\t", " 🥉 User3 calling ethToToken with value of 1 ETH...");
|
||||
const tx1 = await dexContract.connect(user3).ethToToken({
|
||||
value: ethers.parseEther("1"),
|
||||
});
|
||||
await tx1.wait();
|
||||
|
||||
const user3_token_after = await balloonsContract.balanceOf(user3.address);
|
||||
console.log("\t", " 💵 User3 new $BAL balance:", ethers.formatEther(user3_token_after));
|
||||
|
||||
const tokenDifferece = user3_token_after - user3_token_before;
|
||||
console.log("\t", " 🥉 Expecting user3's $BAL balance to increase by the correct amount...");
|
||||
expect(tokenDifferece).to.be.equal("277481486896167099");
|
||||
});
|
||||
// could insert more tests to show the declining price, and what happens when the pool becomes very imbalanced.
|
||||
});
|
||||
|
||||
describe("tokenToEth()", async () => {
|
||||
it("Should send 1 $BAL to DEX in exchange for _ $ETH", async function () {
|
||||
const dexContractAddress = await dexContract.getAddress();
|
||||
const balloons_bal_start = await balloonsContract.balanceOf(dexContractAddress);
|
||||
console.log("\t", " 💵 Initial Ballons $BAL balance:", ethers.formatEther(balloons_bal_start));
|
||||
const dex_eth_start = await ethers.provider.getBalance(dexContractAddress);
|
||||
console.log("\t", " 💵 Initial DEX Eth balance:", ethers.formatEther(dex_eth_start));
|
||||
|
||||
console.log("\t", " 📞 Calling tokenToEth with 1 Eth...");
|
||||
const tx1 = await dexContract.tokenToEth(ethers.parseEther("1"));
|
||||
|
||||
const balloons_bal_end = await balloonsContract.balanceOf(dexContractAddress);
|
||||
console.log("\t", " 💵 Final Ballons $BAL balance:", ethers.formatEther(balloons_bal_end));
|
||||
const dex_eth_end = await ethers.provider.getBalance(dexContractAddress);
|
||||
console.log("\t", " 💵 Final DEX Eth balance:", ethers.formatEther(dex_eth_end));
|
||||
|
||||
await expect(tx1).not.to.be.revertedWith("Contract not initialized");
|
||||
// Checks that the balance of the DEX contract has decreased by 1 $BAL
|
||||
console.log("\t", " 🎈 Expecting the $BAL balance of the Ballon contract to have increased by 1...");
|
||||
expect(await balloonsContract.balanceOf(dexContractAddress)).to.equal(
|
||||
balloons_bal_start + ethers.parseEther("1"),
|
||||
);
|
||||
// Checks that the balance of the DEX contract has increased
|
||||
console.log("\t", " ⚖️ Expecting the balance of the Dex contract to have decreased...");
|
||||
expect(await ethers.provider.getBalance(dexContractAddress)).to.lessThan(dex_eth_start);
|
||||
});
|
||||
|
||||
it("Should revert if 0 tokens sent to the DEX", async function () {
|
||||
await expect(dexContract.tokenToEth(ethers.parseEther("0"))).to.be.reverted;
|
||||
});
|
||||
|
||||
it("Should emit event TokenToEthSwap when tokenToEth() called", async function () {
|
||||
await expect(
|
||||
dexContract.tokenToEth(ethers.parseEther("1")),
|
||||
"Make sure you're emitting the TokenToEthSwap event correctly",
|
||||
).to.emit(dexContract, "TokenToEthSwap");
|
||||
});
|
||||
|
||||
it("Should send less eth after the first trade (tokenToEth() called)", async function () {
|
||||
const dexContractAddress = await dexContract.getAddress();
|
||||
const dex_eth_start = await ethers.provider.getBalance(dexContractAddress);
|
||||
console.log("\t", " 💵 Initial Dex balance:", ethers.formatEther(dex_eth_start));
|
||||
console.log("\t", " 📞 Calling tokenToEth with 1 Eth...");
|
||||
const tx1 = await dexContract.tokenToEth(ethers.parseEther("1"));
|
||||
await tx1.wait();
|
||||
|
||||
const dex_eth_next = await ethers.provider.getBalance(dexContractAddress);
|
||||
const tx1difference = dex_eth_next - dex_eth_start;
|
||||
console.log(
|
||||
"\t",
|
||||
" 💵 Next Dex balance:",
|
||||
ethers.formatEther(dex_eth_next),
|
||||
" Eth sent:",
|
||||
ethers.formatEther(tx1difference * -1n),
|
||||
);
|
||||
|
||||
console.log("\t", " 📞 Calling tokenToEth with 1 Eth...");
|
||||
const tx2 = await dexContract.tokenToEth(ethers.parseEther("1"));
|
||||
await tx2.wait();
|
||||
|
||||
const dex_eth_end = await ethers.provider.getBalance(dexContractAddress);
|
||||
const tx2difference = dex_eth_end - dex_eth_next;
|
||||
console.log(
|
||||
"\t",
|
||||
" 💵 Final Dex balance:",
|
||||
ethers.formatEther(dex_eth_end),
|
||||
" Eth sent:",
|
||||
ethers.formatEther(tx2difference * -1n),
|
||||
);
|
||||
|
||||
console.log("\t", " ➖ Expecting the first call to get more Eth than the second...");
|
||||
expect(tx2difference).to.greaterThan(tx1difference);
|
||||
});
|
||||
});
|
||||
});
|
||||
// ----------------- END OF CHECKPOINT 4 -----------------
|
||||
// ----------------- START OF CHECKPOINT 5 -----------------
|
||||
describe("Checkpoint 5: Liquidity", async () => {
|
||||
describe("deposit()", async () => {
|
||||
deployNewInstance();
|
||||
it("Should increase liquidity in the pool when ETH is deposited", async function () {
|
||||
const dexContractAddress = await dexContract.getAddress();
|
||||
console.log("\t", " 💵 Approving 100 ETH...");
|
||||
await balloonsContract.connect(user2).approve(dexContractAddress, ethers.parseEther("100"));
|
||||
const liquidity_start = await dexContract.totalLiquidity();
|
||||
console.log("\t", " ⚖️ Starting Dex liquidity", ethers.formatEther(liquidity_start));
|
||||
const user2liquidity = await dexContract.getLiquidity(user2.address);
|
||||
console.log("\t", " ⚖️ Expecting user's liquidity to be 0. Liquidity:", ethers.formatEther(user2liquidity));
|
||||
expect(user2liquidity).to.equal("0");
|
||||
console.log("\t", " 🔼 Expecting the deposit function to emit correctly...");
|
||||
await expect(
|
||||
dexContract.connect(user2).deposit((ethers.parseEther("5"), { value: ethers.parseEther("5") })),
|
||||
"Check the order of the values when emitting LiquidityProvided: msg.sender, liquidityMinted, msg.value, tokenDeposit",
|
||||
)
|
||||
.to.emit(dexContract, "LiquidityProvided")
|
||||
.withArgs(anyValue, ethers.parseEther("5"), ethers.parseEther("5"), anyValue);
|
||||
const liquidity_end = await dexContract.totalLiquidity();
|
||||
console.log(
|
||||
"\t",
|
||||
" 💸 Final liquidity should increase by 5. Final liquidity:",
|
||||
ethers.formatEther(liquidity_end),
|
||||
);
|
||||
expect(liquidity_end, "Total liquidity should increase").to.equal(liquidity_start + ethers.parseEther("5"));
|
||||
const user_lp = await dexContract.getLiquidity(user2.address);
|
||||
console.log("\t", " ⚖️ User's liquidity provided should be 5. LP:", ethers.formatEther(user_lp));
|
||||
expect(user_lp.toString(), "User's liquidity provided should be 5.").to.equal(ethers.parseEther("5"));
|
||||
});
|
||||
|
||||
it("Should revert if 0 ETH deposited", async function () {
|
||||
await expect(
|
||||
dexContract.deposit((ethers.parseEther("0"), { value: ethers.parseEther("0") })),
|
||||
"Should revert if 0 value is sent",
|
||||
).to.be.reverted;
|
||||
});
|
||||
});
|
||||
// pool should have 5:5 ETH:$BAL ratio
|
||||
describe("withdraw()", async () => {
|
||||
deployNewInstance();
|
||||
it("Should withdraw 1 ETH and 1 $BAL when pool is at a 1:1 ratio", async function () {
|
||||
const startingLiquidity = await dexContract.totalLiquidity();
|
||||
console.log("\t", " ⚖️ Starting liquidity:", ethers.formatEther(startingLiquidity));
|
||||
const userBallonsBalance = await balloonsContract.balanceOf(deployer.address);
|
||||
console.log("\t", " 💵 User's starting $BAL balance:", ethers.formatEther(userBallonsBalance));
|
||||
|
||||
console.log("\t", " 🔽 Calling withdraw with with a value of 1 Eth...");
|
||||
const tx1 = await dexContract.withdraw(ethers.parseEther("1"));
|
||||
const userBallonsBalanceAfter = await balloonsContract.balanceOf(deployer.address);
|
||||
const tx1_receipt = await tx1.wait();
|
||||
|
||||
const eth_out = await getEventValue(tx1_receipt, 2);
|
||||
const token_out = await getEventValue(tx1_receipt, 3);
|
||||
|
||||
console.log("\t", " 💵 User's new $BAL balance:", ethers.formatEther(userBallonsBalanceAfter));
|
||||
console.log(
|
||||
"\t",
|
||||
" 🎈 Expecting the balance to have increased by 1",
|
||||
ethers.formatEther(userBallonsBalanceAfter),
|
||||
);
|
||||
expect(userBallonsBalance, "User $BAL balance shoud increase").to.equal(
|
||||
userBallonsBalanceAfter - ethers.parseEther("1"),
|
||||
);
|
||||
|
||||
console.log("\t", " 🔽 Expecting the Eth withdrawn emit to be 1...");
|
||||
expect(eth_out, "ethWithdrawn incorrect in emit").to.be.equal(ethers.parseEther("1"));
|
||||
console.log("\t", " 🔽 Expecting the $BAL withdrawn emit to be 1...");
|
||||
expect(token_out, "tokenAmount incorrect in emit").to.be.equal(ethers.parseEther("1"));
|
||||
});
|
||||
|
||||
it("Should revert if sender does not have enough liqudity", async function () {
|
||||
console.log("\t", " ✋ Expecting withdraw to revert when there is not enough liquidity...");
|
||||
await expect(dexContract.withdraw(ethers.parseEther("100"))).to.be.reverted;
|
||||
});
|
||||
|
||||
it("Should decrease total liquidity", async function () {
|
||||
const totalLpBefore = await dexContract.totalLiquidity();
|
||||
console.log("\t", " ⚖️ Initial liquidity:", ethers.formatEther(totalLpBefore));
|
||||
console.log("\t", " 📞 Calling withdraw with 1 Eth...");
|
||||
const txWithdraw = await dexContract.withdraw(ethers.parseEther("1"));
|
||||
const totalLpAfter = await dexContract.totalLiquidity();
|
||||
console.log("\t", " ⚖️ Final liquidity:", ethers.formatEther(totalLpAfter));
|
||||
const txWithdraw_receipt = await txWithdraw.wait();
|
||||
const liquidityBurned = await getEventValue(txWithdraw_receipt, 2);
|
||||
console.log("\t", " 🔼 Emitted liquidity removed:", ethers.formatEther(liquidityBurned));
|
||||
|
||||
expect(totalLpAfter, "Emitted incorrect liquidity amount burned").to.be.equal(totalLpBefore - liquidityBurned);
|
||||
expect(totalLpBefore, "Emitted total liquidity should decrease").to.be.above(totalLpAfter);
|
||||
});
|
||||
|
||||
it("Should emit event LiquidityWithdrawn when withdraw() called", async function () {
|
||||
await expect(
|
||||
dexContract.withdraw(ethers.parseEther("1.5")),
|
||||
"Make sure you emit the LiquidityRemoved event correctly",
|
||||
)
|
||||
.to.emit(dexContract, "LiquidityRemoved")
|
||||
.withArgs(deployer.address, ethers.parseEther("1.5"), ethers.parseEther("1.5"), ethers.parseEther("1.5"));
|
||||
});
|
||||
});
|
||||
});
|
||||
// ----------------- END OF CHECKPOINT 5 -----------------
|
||||
});
|
||||
11
packages/hardhat/tsconfig.json
Normal file
11
packages/hardhat/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
14
packages/nextjs/.env.example
Normal file
14
packages/nextjs/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Template for NextJS environment variables.
|
||||
|
||||
# For local development, copy this file, rename it to .env.local, and fill in the values.
|
||||
# When deploying live, you'll need to store the vars in Vercel/System config.
|
||||
|
||||
# If not set, we provide default values (check `scaffold.config.ts`) so developers can start prototyping out of the box,
|
||||
# but we recommend getting your own API Keys for Production Apps.
|
||||
|
||||
# To access the values stored in this env file you can use: process.env.VARIABLENAME
|
||||
# You'll need to prefix the variables names with NEXT_PUBLIC_ if you want to access them on the client side.
|
||||
# More info: https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables
|
||||
NEXT_PUBLIC_ALCHEMY_API_KEY=
|
||||
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=
|
||||
|
||||
38
packages/nextjs/.gitignore
vendored
Normal file
38
packages/nextjs/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
.vercel
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
ipfs-upload.config.json
|
||||
9
packages/nextjs/.prettierrc.js
Normal file
9
packages/nextjs/.prettierrc.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
arrowParens: "avoid",
|
||||
printWidth: 120,
|
||||
tabWidth: 2,
|
||||
trailingComma: "all",
|
||||
importOrder: ["^react$", "^next/(.*)$", "<THIRD_PARTY_MODULES>", "^@heroicons/(.*)$", "^~~/(.*)$"],
|
||||
importOrderSortSpecifiers: true,
|
||||
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
type AddressCodeTabProps = {
|
||||
bytecode: string;
|
||||
assembly: string;
|
||||
};
|
||||
|
||||
export const AddressCodeTab = ({ bytecode, assembly }: AddressCodeTabProps) => {
|
||||
const formattedAssembly = Array.from(assembly.matchAll(/\w+( 0x[a-fA-F0-9]+)?/g))
|
||||
.map(it => it[0])
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
Bytecode
|
||||
<div className="mockup-code -indent-5 overflow-y-auto max-h-[500px]">
|
||||
<pre className="px-5">
|
||||
<code className="whitespace-pre-wrap overflow-auto break-words">{bytecode}</code>
|
||||
</pre>
|
||||
</div>
|
||||
Opcodes
|
||||
<div className="mockup-code -indent-5 overflow-y-auto max-h-[500px]">
|
||||
<pre className="px-5">
|
||||
<code>{formattedAssembly}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Address } from "viem";
|
||||
import { useContractLogs } from "~~/hooks/scaffold-eth";
|
||||
import { replacer } from "~~/utils/scaffold-eth/common";
|
||||
|
||||
export const AddressLogsTab = ({ address }: { address: Address }) => {
|
||||
const contractLogs = useContractLogs(address);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="mockup-code overflow-auto max-h-[500px]">
|
||||
<pre className="px-5 whitespace-pre-wrap break-words">
|
||||
{contractLogs.map((log, i) => (
|
||||
<div key={i}>
|
||||
<strong>Log:</strong> {JSON.stringify(log, replacer, 2)}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Address, createPublicClient, http, toHex } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
|
||||
const publicClient = createPublicClient({
|
||||
chain: hardhat,
|
||||
transport: http(),
|
||||
});
|
||||
|
||||
export const AddressStorageTab = ({ address }: { address: Address }) => {
|
||||
const [storage, setStorage] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStorage = async () => {
|
||||
try {
|
||||
const storageData = [];
|
||||
let idx = 0;
|
||||
|
||||
while (true) {
|
||||
const storageAtPosition = await publicClient.getStorageAt({
|
||||
address: address,
|
||||
slot: toHex(idx),
|
||||
});
|
||||
|
||||
if (storageAtPosition === "0x" + "0".repeat(64)) break;
|
||||
|
||||
if (storageAtPosition) {
|
||||
storageData.push(storageAtPosition);
|
||||
}
|
||||
|
||||
idx++;
|
||||
}
|
||||
setStorage(storageData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch storage:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStorage();
|
||||
}, [address]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{storage.length > 0 ? (
|
||||
<div className="mockup-code overflow-auto max-h-[500px]">
|
||||
<pre className="px-5 whitespace-pre-wrap break-words">
|
||||
{storage.map((data, i) => (
|
||||
<div key={i}>
|
||||
<strong>Storage Slot {i}:</strong> {data}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-lg">This contract does not have any variables.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
12
packages/nextjs/app/blockexplorer/_components/BackButton.tsx
Normal file
12
packages/nextjs/app/blockexplorer/_components/BackButton.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export const BackButton = () => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<button className="btn btn-sm btn-primary" onClick={() => router.back()}>
|
||||
Back
|
||||
</button>
|
||||
);
|
||||
};
|
||||
102
packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx
Normal file
102
packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AddressCodeTab } from "./AddressCodeTab";
|
||||
import { AddressLogsTab } from "./AddressLogsTab";
|
||||
import { AddressStorageTab } from "./AddressStorageTab";
|
||||
import { PaginationButton } from "./PaginationButton";
|
||||
import { TransactionsTable } from "./TransactionsTable";
|
||||
import { Address, createPublicClient, http } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useFetchBlocks } from "~~/hooks/scaffold-eth";
|
||||
|
||||
type AddressCodeTabProps = {
|
||||
bytecode: string;
|
||||
assembly: string;
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
address: Address;
|
||||
contractData: AddressCodeTabProps | null;
|
||||
};
|
||||
|
||||
const publicClient = createPublicClient({
|
||||
chain: hardhat,
|
||||
transport: http(),
|
||||
});
|
||||
|
||||
export const ContractTabs = ({ address, contractData }: PageProps) => {
|
||||
const { blocks, transactionReceipts, currentPage, totalBlocks, setCurrentPage } = useFetchBlocks();
|
||||
const [activeTab, setActiveTab] = useState("transactions");
|
||||
const [isContract, setIsContract] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkIsContract = async () => {
|
||||
const contractCode = await publicClient.getBytecode({ address: address });
|
||||
setIsContract(contractCode !== undefined && contractCode !== "0x");
|
||||
};
|
||||
|
||||
checkIsContract();
|
||||
}, [address]);
|
||||
|
||||
const filteredBlocks = blocks.filter(block =>
|
||||
block.transactions.some(tx => {
|
||||
if (typeof tx === "string") {
|
||||
return false;
|
||||
}
|
||||
return tx.from.toLowerCase() === address.toLowerCase() || tx.to?.toLowerCase() === address.toLowerCase();
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isContract && (
|
||||
<div role="tablist" className="tabs tabs-lift">
|
||||
<button
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "transactions" ? "tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("transactions")}
|
||||
>
|
||||
Transactions
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "code" ? "tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("code")}
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "storage" ? "tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("storage")}
|
||||
>
|
||||
Storage
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "logs" ? "tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("logs")}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "transactions" && (
|
||||
<div className="pt-4">
|
||||
<TransactionsTable blocks={filteredBlocks} transactionReceipts={transactionReceipts} />
|
||||
<PaginationButton
|
||||
currentPage={currentPage}
|
||||
totalItems={Number(totalBlocks)}
|
||||
setCurrentPage={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "code" && contractData && (
|
||||
<AddressCodeTab bytecode={contractData.bytecode} assembly={contractData.assembly} />
|
||||
)}
|
||||
{activeTab === "storage" && <AddressStorageTab address={address} />}
|
||||
{activeTab === "logs" && <AddressLogsTab address={address} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type PaginationButtonProps = {
|
||||
currentPage: number;
|
||||
totalItems: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
};
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export const PaginationButton = ({ currentPage, totalItems, setCurrentPage }: PaginationButtonProps) => {
|
||||
const isPrevButtonDisabled = currentPage === 0;
|
||||
const isNextButtonDisabled = currentPage + 1 >= Math.ceil(totalItems / ITEMS_PER_PAGE);
|
||||
|
||||
const prevButtonClass = isPrevButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
|
||||
const nextButtonClass = isNextButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
|
||||
|
||||
if (isNextButtonDisabled && isPrevButtonDisabled) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-5 justify-end flex gap-3 mx-5">
|
||||
<button
|
||||
className={`btn btn-sm ${prevButtonClass}`}
|
||||
disabled={isPrevButtonDisabled}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="self-center text-primary-content font-medium">Page {currentPage + 1}</span>
|
||||
<button
|
||||
className={`btn btn-sm ${nextButtonClass}`}
|
||||
disabled={isNextButtonDisabled}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
49
packages/nextjs/app/blockexplorer/_components/SearchBar.tsx
Normal file
49
packages/nextjs/app/blockexplorer/_components/SearchBar.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isAddress, isHex } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { usePublicClient } from "wagmi";
|
||||
|
||||
export const SearchBar = () => {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const client = usePublicClient({ chainId: hardhat.id });
|
||||
|
||||
const handleSearch = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (isHex(searchInput)) {
|
||||
try {
|
||||
const tx = await client?.getTransaction({ hash: searchInput });
|
||||
if (tx) {
|
||||
router.push(`/blockexplorer/transaction/${searchInput}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transaction:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAddress(searchInput)) {
|
||||
router.push(`/blockexplorer/address/${searchInput}`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSearch} className="flex items-center justify-end mb-5 space-x-3 mx-5">
|
||||
<input
|
||||
className="border-primary bg-base-100 text-base-content placeholder:text-base-content/50 p-2 mr-2 w-full md:w-1/2 lg:w-1/3 rounded-md shadow-md focus:outline-hidden focus:ring-2 focus:ring-accent"
|
||||
type="text"
|
||||
value={searchInput}
|
||||
placeholder="Search by hash or address"
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-sm btn-primary" type="submit">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
|
||||
import { useCopyToClipboard } from "~~/hooks/scaffold-eth/useCopyToClipboard";
|
||||
|
||||
export const TransactionHash = ({ hash }: { hash: string }) => {
|
||||
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
|
||||
useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Link href={`/blockexplorer/transaction/${hash}`}>
|
||||
{hash?.substring(0, 6)}...{hash?.substring(hash.length - 4)}
|
||||
</Link>
|
||||
{isAddressCopiedToClipboard ? (
|
||||
<CheckCircleIcon
|
||||
className="ml-1.5 text-xl font-normal text-base-content h-5 w-5 cursor-pointer"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<DocumentDuplicateIcon
|
||||
className="ml-1.5 text-xl font-normal h-5 w-5 cursor-pointer"
|
||||
aria-hidden="true"
|
||||
onClick={() => copyAddressToClipboard(hash)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
};
|
||||
7
packages/nextjs/app/blockexplorer/_components/index.tsx
Normal file
7
packages/nextjs/app/blockexplorer/_components/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./SearchBar";
|
||||
export * from "./BackButton";
|
||||
export * from "./AddressCodeTab";
|
||||
export * from "./TransactionHash";
|
||||
export * from "./ContractTabs";
|
||||
export * from "./PaginationButton";
|
||||
export * from "./TransactionsTable";
|
||||
101
packages/nextjs/app/blockexplorer/address/[address]/page.tsx
Normal file
101
packages/nextjs/app/blockexplorer/address/[address]/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Address } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { AddressComponent } from "~~/app/blockexplorer/_components/AddressComponent";
|
||||
import deployedContracts from "~~/contracts/deployedContracts";
|
||||
import { isZeroAddress } from "~~/utils/scaffold-eth/common";
|
||||
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ address: Address }>;
|
||||
};
|
||||
|
||||
async function fetchByteCodeAndAssembly(buildInfoDirectory: string, contractPath: string) {
|
||||
const buildInfoFiles = fs.readdirSync(buildInfoDirectory);
|
||||
let bytecode = "";
|
||||
let assembly = "";
|
||||
|
||||
for (let i = 0; i < buildInfoFiles.length; i++) {
|
||||
const filePath = path.join(buildInfoDirectory, buildInfoFiles[i]);
|
||||
|
||||
const buildInfo = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
|
||||
if (buildInfo.output.contracts[contractPath]) {
|
||||
for (const contract in buildInfo.output.contracts[contractPath]) {
|
||||
bytecode = buildInfo.output.contracts[contractPath][contract].evm.bytecode.object;
|
||||
assembly = buildInfo.output.contracts[contractPath][contract].evm.bytecode.opcodes;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bytecode && assembly) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { bytecode, assembly };
|
||||
}
|
||||
|
||||
const getContractData = async (address: Address) => {
|
||||
const contracts = deployedContracts as GenericContractsDeclaration | null;
|
||||
const chainId = hardhat.id;
|
||||
|
||||
if (!contracts || !contracts[chainId] || Object.keys(contracts[chainId]).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let contractPath = "";
|
||||
|
||||
const buildInfoDirectory = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"hardhat",
|
||||
"artifacts",
|
||||
"build-info",
|
||||
);
|
||||
|
||||
if (!fs.existsSync(buildInfoDirectory)) {
|
||||
throw new Error(`Directory ${buildInfoDirectory} not found.`);
|
||||
}
|
||||
|
||||
const deployedContractsOnChain = contracts[chainId];
|
||||
for (const [contractName, contractInfo] of Object.entries(deployedContractsOnChain)) {
|
||||
if (contractInfo.address.toLowerCase() === address.toLowerCase()) {
|
||||
contractPath = `contracts/${contractName}.sol`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!contractPath) {
|
||||
// No contract found at this address
|
||||
return null;
|
||||
}
|
||||
|
||||
const { bytecode, assembly } = await fetchByteCodeAndAssembly(buildInfoDirectory, contractPath);
|
||||
|
||||
return { bytecode, assembly };
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
// An workaround to enable static exports in Next.js, generating single dummy page.
|
||||
return [{ address: "0x0000000000000000000000000000000000000000" }];
|
||||
}
|
||||
|
||||
const AddressPage = async (props: PageProps) => {
|
||||
const params = await props.params;
|
||||
const address = params?.address as Address;
|
||||
|
||||
if (isZeroAddress(address)) return null;
|
||||
|
||||
const contractData: { bytecode: string; assembly: string } | null = await getContractData(address);
|
||||
return <AddressComponent address={address} contractData={contractData} />;
|
||||
};
|
||||
|
||||
export default AddressPage;
|
||||
12
packages/nextjs/app/blockexplorer/layout.tsx
Normal file
12
packages/nextjs/app/blockexplorer/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
|
||||
|
||||
export const metadata = getMetadata({
|
||||
title: "Block Explorer",
|
||||
description: "Block Explorer created with 🏗 Scaffold-ETH 2",
|
||||
});
|
||||
|
||||
const BlockExplorerLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default BlockExplorerLayout;
|
||||
83
packages/nextjs/app/blockexplorer/page.tsx
Normal file
83
packages/nextjs/app/blockexplorer/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { PaginationButton, SearchBar, TransactionsTable } from "./_components";
|
||||
import type { NextPage } from "next";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useFetchBlocks } from "~~/hooks/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
const BlockExplorer: NextPage = () => {
|
||||
const { blocks, transactionReceipts, currentPage, totalBlocks, setCurrentPage, error } = useFetchBlocks();
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const [isLocalNetwork, setIsLocalNetwork] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetNetwork.id !== hardhat.id) {
|
||||
setIsLocalNetwork(false);
|
||||
}
|
||||
}, [targetNetwork.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetNetwork.id === hardhat.id && error) {
|
||||
setHasError(true);
|
||||
}
|
||||
}, [targetNetwork.id, error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLocalNetwork) {
|
||||
notification.error(
|
||||
<>
|
||||
<p className="font-bold mt-0 mb-1">
|
||||
<code className="italic bg-base-300 text-base font-bold"> targetNetwork </code> is not localhost
|
||||
</p>
|
||||
<p className="m-0">
|
||||
- You are on <code className="italic bg-base-300 text-base font-bold">{targetNetwork.name}</code> .This
|
||||
block explorer is only for <code className="italic bg-base-300 text-base font-bold">localhost</code>.
|
||||
</p>
|
||||
<p className="mt-1 break-normal">
|
||||
- You can use{" "}
|
||||
<a className="text-accent" href={targetNetwork.blockExplorers?.default.url}>
|
||||
{targetNetwork.blockExplorers?.default.name}
|
||||
</a>{" "}
|
||||
instead
|
||||
</p>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isLocalNetwork,
|
||||
targetNetwork.blockExplorers?.default.name,
|
||||
targetNetwork.blockExplorers?.default.url,
|
||||
targetNetwork.name,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasError) {
|
||||
notification.error(
|
||||
<>
|
||||
<p className="font-bold mt-0 mb-1">Cannot connect to local provider</p>
|
||||
<p className="m-0">
|
||||
- Did you forget to run <code className="italic bg-base-300 text-base font-bold">yarn chain</code> ?
|
||||
</p>
|
||||
<p className="mt-1 break-normal">
|
||||
- Or you can change <code className="italic bg-base-300 text-base font-bold">targetNetwork</code> in{" "}
|
||||
<code className="italic bg-base-300 text-base font-bold">scaffold.config.ts</code>
|
||||
</p>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
}, [hasError]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto my-10">
|
||||
<SearchBar />
|
||||
<TransactionsTable blocks={blocks} transactionReceipts={transactionReceipts} />
|
||||
<PaginationButton currentPage={currentPage} totalItems={Number(totalBlocks)} setCurrentPage={setCurrentPage} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockExplorer;
|
||||
@@ -0,0 +1,23 @@
|
||||
import TransactionComp from "../_components/TransactionComp";
|
||||
import type { NextPage } from "next";
|
||||
import { Hash } from "viem";
|
||||
import { isZeroAddress } from "~~/utils/scaffold-eth/common";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ txHash?: Hash }>;
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
// An workaround to enable static exports in Next.js, generating single dummy page.
|
||||
return [{ txHash: "0x0000000000000000000000000000000000000000" }];
|
||||
}
|
||||
const TransactionPage: NextPage<PageProps> = async (props: PageProps) => {
|
||||
const params = await props.params;
|
||||
const txHash = params?.txHash as Hash;
|
||||
|
||||
if (isZeroAddress(txHash)) return null;
|
||||
|
||||
return <TransactionComp txHash={txHash} />;
|
||||
};
|
||||
|
||||
export default TransactionPage;
|
||||
@@ -0,0 +1,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;
|
||||
73
packages/nextjs/app/debug/_components/DebugContracts.tsx
Normal file
73
packages/nextjs/app/debug/_components/DebugContracts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
104
packages/nextjs/app/debug/_components/contract/ContractUI.tsx
Normal file
104
packages/nextjs/app/debug/_components/contract/ContractUI.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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-normal"
|
||||
data-tip={`Inherited from: ${inheritedFrom}`}
|
||||
>
|
||||
<InformationCircleIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, 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 = transformAbiFunction(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>
|
||||
);
|
||||
};
|
||||
44
packages/nextjs/app/debug/_components/contract/Tuple.tsx
Normal file
44
packages/nextjs/app/debug/_components/contract/Tuple.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
142
packages/nextjs/app/debug/_components/contract/TupleArray.tsx
Normal file
142
packages/nextjs/app/debug/_components/contract/TupleArray.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
42
packages/nextjs/app/debug/_components/contract/TxReceipt.tsx
Normal file
42
packages/nextjs/app/debug/_components/contract/TxReceipt.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, 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 { 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 });
|
||||
|
||||
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]);
|
||||
|
||||
// TODO use `useMemo` to optimize also update in ReadOnlyFunctionForm
|
||||
const transformedFunction = transformAbiFunction(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>
|
||||
);
|
||||
};
|
||||
8
packages/nextjs/app/debug/_components/contract/index.tsx
Normal file
8
packages/nextjs/app/debug/_components/contract/index.tsx
Normal 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";
|
||||
166
packages/nextjs/app/debug/_components/contract/utilsContract.tsx
Normal file
166
packages/nextjs/app/debug/_components/contract/utilsContract.tsx
Normal 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,
|
||||
};
|
||||
114
packages/nextjs/app/debug/_components/contract/utilsDisplay.tsx
Normal file
114
packages/nextjs/app/debug/_components/contract/utilsDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
28
packages/nextjs/app/debug/page.tsx
Normal file
28
packages/nextjs/app/debug/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { DebugContracts } from "./_components/DebugContracts";
|
||||
import type { NextPage } from "next";
|
||||
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
|
||||
|
||||
export const metadata = getMetadata({
|
||||
title: "Debug Contracts",
|
||||
description: "Debug your deployed 🏗 Scaffold-ETH 2 contracts in an easy way",
|
||||
});
|
||||
|
||||
const Debug: NextPage = () => {
|
||||
return (
|
||||
<>
|
||||
<DebugContracts />
|
||||
<div className="text-center mt-8 bg-secondary p-10">
|
||||
<h1 className="text-4xl my-0">Debug Contracts</h1>
|
||||
<p className="text-neutral">
|
||||
You can debug & interact with your deployed contracts here.
|
||||
<br /> Check{" "}
|
||||
<code className="italic bg-base-300 text-base font-bold [word-spacing:-0.5rem] px-1">
|
||||
packages / nextjs / app / debug / page.tsx
|
||||
</code>{" "}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Debug;
|
||||
198
packages/nextjs/app/dex/_components/Curve.tsx
Normal file
198
packages/nextjs/app/dex/_components/Curve.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import { FC, useEffect, useRef } from "react";
|
||||
|
||||
const drawArrow = (ctx: CanvasRenderingContext2D, x1: number, y1: number, x2: number, y2: number) => {
|
||||
const [dx, dy] = [x1 - x2, y1 - y2];
|
||||
const norm = Math.sqrt(dx * dx + dy * dy);
|
||||
const [udx, udy] = [dx / norm, dy / norm];
|
||||
const size = norm / 7;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
ctx.moveTo(x2, y2);
|
||||
ctx.lineTo(x2 + udx * size - udy * size, y2 + udx * size + udy * size);
|
||||
ctx.moveTo(x2, y2);
|
||||
ctx.lineTo(x2 + udx * size + udy * size, y2 - udx * size + udy * size);
|
||||
ctx.stroke();
|
||||
};
|
||||
|
||||
export interface ICurveProps {
|
||||
ethReserve: number;
|
||||
tokenReserve: number;
|
||||
addingEth: number;
|
||||
addingToken: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export const Curve: FC<ICurveProps> = (props: ICurveProps) => {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = ref.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const textSize = 12;
|
||||
|
||||
const width = canvas.width;
|
||||
const height = canvas.height;
|
||||
|
||||
if (props.ethReserve && props.tokenReserve) {
|
||||
const k = props.ethReserve * props.tokenReserve;
|
||||
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (ctx == null) {
|
||||
return;
|
||||
}
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
let maxX = k / (props.ethReserve / 4);
|
||||
let minX = 0;
|
||||
|
||||
if (props.addingEth || props.addingToken) {
|
||||
maxX = k / (props.ethReserve * 0.4);
|
||||
//maxX = k/(props.ethReserve*0.8)
|
||||
minX = k / Math.max(0, 500 - props.ethReserve);
|
||||
}
|
||||
|
||||
const maxY = (maxX * height) / width;
|
||||
const minY = (minX * height) / width;
|
||||
|
||||
const plotX = (x: number) => {
|
||||
return ((x - minX) / (maxX - minX)) * width;
|
||||
};
|
||||
|
||||
const plotY = (y: number) => {
|
||||
return height - ((y - minY) / (maxY - minY)) * height;
|
||||
};
|
||||
ctx.strokeStyle = "#000000";
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.font = textSize + "px Arial";
|
||||
// +Y axis
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(plotX(minX), plotY(0));
|
||||
ctx.lineTo(plotX(minX), plotY(maxY));
|
||||
ctx.stroke();
|
||||
// +X axis
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(plotX(0), plotY(minY));
|
||||
ctx.lineTo(plotX(maxX), plotY(minY));
|
||||
ctx.stroke();
|
||||
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
let first = true;
|
||||
for (let x = minX; x <= maxX; x += maxX / width) {
|
||||
/////
|
||||
const y = k / x;
|
||||
/////
|
||||
if (first) {
|
||||
ctx.moveTo(plotX(x), plotY(y));
|
||||
first = false;
|
||||
} else {
|
||||
ctx.lineTo(plotX(x), plotY(y));
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
if (props.addingEth) {
|
||||
const newEthReserve = props.ethReserve + parseFloat(props.addingEth.toString());
|
||||
|
||||
ctx.fillStyle = "#bbbbbb";
|
||||
ctx.beginPath();
|
||||
ctx.arc(plotX(newEthReserve), plotY(k / newEthReserve), 5, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
ctx.strokeStyle = "#009900";
|
||||
drawArrow(
|
||||
ctx,
|
||||
plotX(props.ethReserve),
|
||||
plotY(props.tokenReserve),
|
||||
plotX(newEthReserve),
|
||||
plotY(props.tokenReserve),
|
||||
);
|
||||
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillText(
|
||||
"" + props.addingEth + " ETH input",
|
||||
plotX(props.ethReserve) + textSize,
|
||||
plotY(props.tokenReserve) - textSize,
|
||||
);
|
||||
|
||||
ctx.strokeStyle = "#990000";
|
||||
drawArrow(ctx, plotX(newEthReserve), plotY(props.tokenReserve), plotX(newEthReserve), plotY(k / newEthReserve));
|
||||
|
||||
const amountGained = Math.round((10000 * (props.addingEth * props.tokenReserve)) / newEthReserve) / 10000;
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillText(
|
||||
"" + amountGained + " 🎈 output (-0.3% fee)",
|
||||
plotX(newEthReserve) + textSize,
|
||||
plotY(k / newEthReserve),
|
||||
);
|
||||
} else if (props.addingToken) {
|
||||
const newTokenReserve = props.tokenReserve + parseFloat(props.addingToken.toString());
|
||||
|
||||
ctx.fillStyle = "#bbbbbb";
|
||||
ctx.beginPath();
|
||||
ctx.arc(plotX(k / newTokenReserve), plotY(newTokenReserve), 5, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
|
||||
//console.log("newTokenReserve",newTokenReserve)
|
||||
ctx.strokeStyle = "#990000";
|
||||
drawArrow(
|
||||
ctx,
|
||||
plotX(props.ethReserve),
|
||||
plotY(props.tokenReserve),
|
||||
plotX(props.ethReserve),
|
||||
plotY(newTokenReserve),
|
||||
);
|
||||
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillText(
|
||||
"" + props.addingToken + " 🎈 input",
|
||||
plotX(props.ethReserve) + textSize,
|
||||
plotY(props.tokenReserve),
|
||||
);
|
||||
|
||||
ctx.strokeStyle = "#009900";
|
||||
drawArrow(
|
||||
ctx,
|
||||
plotX(props.ethReserve),
|
||||
plotY(newTokenReserve),
|
||||
plotX(k / newTokenReserve),
|
||||
plotY(newTokenReserve),
|
||||
);
|
||||
|
||||
const amountGained = Math.round((10000 * (props.addingToken * props.ethReserve)) / newTokenReserve) / 10000;
|
||||
//console.log("amountGained",amountGained)
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillText(
|
||||
"" + amountGained + " ETH output (-0.3% fee)",
|
||||
plotX(k / newTokenReserve) + textSize,
|
||||
plotY(newTokenReserve) - textSize,
|
||||
);
|
||||
}
|
||||
|
||||
ctx.fillStyle = "#0000FF";
|
||||
ctx.beginPath();
|
||||
ctx.arc(plotX(props.ethReserve), plotY(props.tokenReserve), 5, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
}
|
||||
}, [props]);
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative", width: props.width, height: props.height }}>
|
||||
<canvas style={{ position: "absolute", left: 0, top: 0 }} ref={ref} width={props.width} height={props.height} />
|
||||
<div style={{ position: "absolute", left: "20%", bottom: -20 }}>-- ETH Reserve --{">"}</div>
|
||||
<div
|
||||
style={{ position: "absolute", left: -20, bottom: "20%", transform: "rotate(-90deg)", transformOrigin: "0 0" }}
|
||||
>
|
||||
-- Token Reserve --{">"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
packages/nextjs/app/dex/_components/index.tsx
Normal file
1
packages/nextjs/app/dex/_components/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./Curve";
|
||||
298
packages/nextjs/app/dex/page.tsx
Normal file
298
packages/nextjs/app/dex/page.tsx
Normal file
@@ -0,0 +1,298 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Curve } from "./_components";
|
||||
import type { NextPage } from "next";
|
||||
import { Address as AddressType, formatEther, isAddress, parseEther } from "viem";
|
||||
import { useAccount } from "wagmi";
|
||||
import { Address, AddressInput, Balance, EtherInput, IntegerInput } from "~~/components/scaffold-eth";
|
||||
import { useDeployedContractInfo, useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
|
||||
import { useWatchBalance } from "~~/hooks/scaffold-eth/useWatchBalance";
|
||||
|
||||
// REGEX for number inputs (only allow numbers and a single decimal point)
|
||||
const NUMBER_REGEX = /^\.?\d+\.?\d*$/;
|
||||
|
||||
const Dex: NextPage = () => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [ethToTokenAmount, setEthToTokenAmount] = useState("");
|
||||
const [tokenToETHAmount, setTokenToETHAmount] = useState("");
|
||||
const [depositAmount, setDepositAmount] = useState("");
|
||||
const [withdrawAmount, setWithdrawAmount] = useState("");
|
||||
const [approveSpender, setApproveSpender] = useState("");
|
||||
const [approveAmount, setApproveAmount] = useState("");
|
||||
const [accountBalanceOf, setAccountBalanceOf] = useState("");
|
||||
|
||||
const { data: DEXInfo } = useDeployedContractInfo({ contractName: "DEX" });
|
||||
const { data: BalloonsInfo } = useDeployedContractInfo({ contractName: "Balloons" });
|
||||
const { address: connectedAccount } = useAccount();
|
||||
|
||||
const { data: DEXBalloonBalance } = useScaffoldReadContract({
|
||||
contractName: "Balloons",
|
||||
functionName: "balanceOf",
|
||||
args: [DEXInfo?.address?.toString()],
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (DEXBalloonBalance !== undefined) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [DEXBalloonBalance]);
|
||||
|
||||
const { data: DEXtotalLiquidity } = useScaffoldReadContract({
|
||||
contractName: "DEX",
|
||||
functionName: "totalLiquidity",
|
||||
});
|
||||
|
||||
const { writeContractAsync: writeDexContractAsync } = useScaffoldWriteContract({ contractName: "DEX" });
|
||||
|
||||
const { writeContractAsync: writeBalloonsContractAsync } = useScaffoldWriteContract({ contractName: "Balloons" });
|
||||
|
||||
const { data: balanceOfWrite } = useScaffoldReadContract({
|
||||
contractName: "Balloons",
|
||||
functionName: "balanceOf",
|
||||
args: [accountBalanceOf as AddressType],
|
||||
query: {
|
||||
enabled: isAddress(accountBalanceOf),
|
||||
},
|
||||
});
|
||||
|
||||
const { data: contractBalance } = useScaffoldReadContract({
|
||||
contractName: "Balloons",
|
||||
functionName: "balanceOf",
|
||||
args: [DEXInfo?.address],
|
||||
});
|
||||
|
||||
const { data: userBalloons } = useScaffoldReadContract({
|
||||
contractName: "Balloons",
|
||||
functionName: "balanceOf",
|
||||
args: [connectedAccount],
|
||||
});
|
||||
|
||||
const { data: userLiquidity } = useScaffoldReadContract({
|
||||
contractName: "DEX",
|
||||
functionName: "getLiquidity",
|
||||
args: [connectedAccount],
|
||||
});
|
||||
|
||||
const { data: contractETHBalance } = useWatchBalance({ address: DEXInfo?.address });
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-center mb-4 mt-5">
|
||||
<span className="block text-xl text-right mr-7">
|
||||
🎈: {parseFloat(formatEther(userBalloons || 0n)).toFixed(4)}
|
||||
</span>
|
||||
<span className="block text-xl text-right mr-7">
|
||||
💦💦: {parseFloat(formatEther(userLiquidity || 0n)).toFixed(4)}
|
||||
</span>
|
||||
<span className="block text-2xl mb-2">SpeedRunEthereum</span>
|
||||
<span className="block text-4xl font-bold">Challenge: ⚖️ Build a DEX </span>
|
||||
</h1>
|
||||
<div className="items-start pt-10 grid grid-cols-1 md:grid-cols-2 content-start">
|
||||
<div className="px-5 py-5">
|
||||
<div className="bg-base-100 shadow-lg shadow-secondary border-8 border-secondary rounded-xl p-8 m-8">
|
||||
<div className="flex flex-col text-center">
|
||||
<span className="text-3xl font-semibold mb-2">DEX Contract</span>
|
||||
<span className="block text-2xl mb-2 mx-auto">
|
||||
<Address size="xl" address={DEXInfo?.address} />
|
||||
</span>
|
||||
<span className="flex flex-row mx-auto mt-5">
|
||||
{" "}
|
||||
<Balance className="text-xl" address={DEXInfo?.address} /> ⚖️
|
||||
{isLoading ? (
|
||||
<span>Loading...</span>
|
||||
) : (
|
||||
<span className="pl-8 text-xl">🎈 {parseFloat(formatEther(DEXBalloonBalance || 0n)).toFixed(4)}</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="py-3 px-4">
|
||||
<div className="flex mb-4 justify-center items-center">
|
||||
<span className="w-1/2">
|
||||
ethToToken{" "}
|
||||
<EtherInput
|
||||
value={ethToTokenAmount}
|
||||
onChange={value => {
|
||||
setTokenToETHAmount("");
|
||||
setEthToTokenAmount(value);
|
||||
}}
|
||||
name="ethToToken"
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-primary h-[2.2rem] min-h-[2.2rem] mt-6 mx-5"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await writeDexContractAsync({
|
||||
functionName: "ethToToken",
|
||||
value: NUMBER_REGEX.test(ethToTokenAmount) ? parseEther(ethToTokenAmount) : 0n,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error calling ethToToken function", err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-center items-center">
|
||||
<span className="w-1/2">
|
||||
tokenToETH{" "}
|
||||
<IntegerInput
|
||||
value={tokenToETHAmount}
|
||||
onChange={value => {
|
||||
setEthToTokenAmount("");
|
||||
setTokenToETHAmount(value.toString());
|
||||
}}
|
||||
name="tokenToETH"
|
||||
disableMultiplyBy1e18
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-primary h-[2.2rem] min-h-[2.2rem] mt-6 mx-5"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await writeDexContractAsync({
|
||||
functionName: "tokenToEth",
|
||||
// @ts-expect-error - Show error on frontend while sending, if user types invalid number
|
||||
args: [NUMBER_REGEX.test(tokenToETHAmount) ? parseEther(tokenToETHAmount) : tokenToETHAmount],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error calling tokenToEth function", err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-center text-primary-content text-xl mt-8 -ml-8">
|
||||
Liquidity ({DEXtotalLiquidity ? parseFloat(formatEther(DEXtotalLiquidity || 0n)).toFixed(4) : "None"})
|
||||
</p>
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex mb-4 justify-center items-center">
|
||||
<span className="w-1/2">
|
||||
Deposit <EtherInput value={depositAmount} onChange={value => setDepositAmount(value)} />
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-primary h-[2.2rem] min-h-[2.2rem] mt-6 mx-5"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await writeDexContractAsync({
|
||||
functionName: "deposit",
|
||||
value: NUMBER_REGEX.test(depositAmount) ? parseEther(depositAmount) : 0n,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error calling deposit function", err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center items-center">
|
||||
<span className="w-1/2">
|
||||
Withdraw <EtherInput value={withdrawAmount} onChange={value => setWithdrawAmount(value)} />
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-primary h-[2.2rem] min-h-[2.2rem] mt-6 mx-5"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await writeDexContractAsync({
|
||||
functionName: "withdraw",
|
||||
// @ts-expect-error - Show error on frontend while sending, if user types invalid number
|
||||
args: [NUMBER_REGEX.test(withdrawAmount) ? parseEther(withdrawAmount) : withdrawAmount],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error calling withdraw function", err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 bg-base-100 shadow-lg shadow-secondary border-8 border-secondary rounded-xl py-5 p-8 m-8">
|
||||
<div className="flex flex-col text-center mt-2 mb-4 px-4">
|
||||
<span className="block text-3xl font-semibold mb-2">Balloons</span>
|
||||
<span className="mx-auto">
|
||||
<Address size="xl" address={BalloonsInfo?.address} />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className=" px-4 py-3">
|
||||
<div className="flex flex-col gap-4 mb-4 justify-center items-center">
|
||||
<span className="w-1/2">
|
||||
Approve{" "}
|
||||
<AddressInput
|
||||
value={approveSpender ?? ""}
|
||||
onChange={value => setApproveSpender(value)}
|
||||
placeholder="Address Spender"
|
||||
/>
|
||||
</span>
|
||||
<span className="w-1/2">
|
||||
<IntegerInput
|
||||
value={approveAmount}
|
||||
onChange={value => setApproveAmount(value.toString())}
|
||||
placeholder="Amount"
|
||||
disableMultiplyBy1e18
|
||||
/>
|
||||
</span>
|
||||
<button
|
||||
className="btn btn-primary h-[2.2rem] min-h-[2.2rem] mt-auto"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await writeBalloonsContractAsync({
|
||||
functionName: "approve",
|
||||
args: [
|
||||
approveSpender as AddressType,
|
||||
// @ts-expect-error - Show error on frontend while sending, if user types invalid number
|
||||
NUMBER_REGEX.test(approveAmount) ? parseEther(approveAmount) : approveAmount,
|
||||
],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error calling approve function", err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
<span className="w-1/2">
|
||||
balanceOf{" "}
|
||||
<AddressInput
|
||||
value={accountBalanceOf}
|
||||
onChange={value => setAccountBalanceOf(value)}
|
||||
placeholder="address Account"
|
||||
/>
|
||||
</span>
|
||||
{balanceOfWrite === undefined ? (
|
||||
<h1></h1>
|
||||
) : (
|
||||
<span className="font-bold bg-primary px-3 rounded-2xl">
|
||||
BAL Balance: {parseFloat(formatEther(balanceOfWrite || 0n)).toFixed(4)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto p-8 m-8 md:sticky md:top-0">
|
||||
<Curve
|
||||
addingEth={ethToTokenAmount !== "" ? parseFloat(ethToTokenAmount.toString()) : 0}
|
||||
addingToken={tokenToETHAmount !== "" ? parseFloat(tokenToETHAmount.toString()) : 0}
|
||||
ethReserve={parseFloat(formatEther(contractETHBalance?.value || 0n))}
|
||||
tokenReserve={parseFloat(formatEther(contractBalance || 0n))}
|
||||
width={500}
|
||||
height={500}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dex;
|
||||
216
packages/nextjs/app/events/page.tsx
Normal file
216
packages/nextjs/app/events/page.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
"use client";
|
||||
|
||||
import type { NextPage } from "next";
|
||||
import { formatEther } from "viem";
|
||||
import { Address } from "~~/components/scaffold-eth";
|
||||
import { useScaffoldEventHistory } from "~~/hooks/scaffold-eth";
|
||||
|
||||
const Events: NextPage = () => {
|
||||
const { data: EthToTokenEvents, isLoading: isEthToTokenEventsLoading } = useScaffoldEventHistory({
|
||||
contractName: "DEX",
|
||||
eventName: "EthToTokenSwap",
|
||||
});
|
||||
|
||||
const { data: tokenToEthEvents, isLoading: isTokenToEthEventsLoading } = useScaffoldEventHistory({
|
||||
contractName: "DEX",
|
||||
eventName: "TokenToEthSwap",
|
||||
});
|
||||
|
||||
const { data: liquidityProvidedEvents, isLoading: isLiquidityProvidedEventsLoading } = useScaffoldEventHistory({
|
||||
contractName: "DEX",
|
||||
eventName: "LiquidityProvided",
|
||||
});
|
||||
|
||||
const { data: liquidityRemovedEvents, isLoading: isLiquidityRemovedEventsLoading } = useScaffoldEventHistory({
|
||||
contractName: "DEX",
|
||||
eventName: "LiquidityRemoved",
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center flex-col flex-grow pt-10">
|
||||
{isEthToTokenEventsLoading ? (
|
||||
<div className="flex justify-center items-center mt-10">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-center mb-4">
|
||||
<span className="block text-2xl font-bold">ETH To Balloons Events</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto shadow-lg">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="bg-primary">Address</th>
|
||||
<th className="bg-primary">Amount of ETH in</th>
|
||||
<th className="bg-primary">Amount of Balloons out</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!EthToTokenEvents || EthToTokenEvents.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center">
|
||||
No events found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
EthToTokenEvents?.map((event, index) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="text-center">
|
||||
<Address address={event.args.swapper} />
|
||||
</td>
|
||||
<td>{parseFloat(formatEther(event.args.ethInput || 0n)).toFixed(4)}</td>
|
||||
<td>{parseFloat(formatEther(event.args.tokenOutput || 0n)).toFixed(4)}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isTokenToEthEventsLoading ? (
|
||||
<div className="flex justify-center items-center mt-10">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8">
|
||||
<div className="text-center mb-4">
|
||||
<span className="block text-2xl font-bold">Balloons To ETH Events</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto shadow-lg">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="bg-primary">Address</th>
|
||||
<th className="bg-primary">Amount of Balloons In</th>
|
||||
<th className="bg-primary">Amount of ETH Out</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!tokenToEthEvents || tokenToEthEvents.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center">
|
||||
No events found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tokenToEthEvents?.map((event, index) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="text-center">
|
||||
<Address address={event.args.swapper} />
|
||||
</td>
|
||||
<td>{parseFloat(formatEther(event.args.tokensInput || 0n)).toFixed(4)}</td>
|
||||
<td>{parseFloat(formatEther(event.args.ethOutput || 0n)).toFixed(4)}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLiquidityProvidedEventsLoading ? (
|
||||
<div className="flex justify-center items-center mt-10">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8">
|
||||
<div className="text-center mb-4">
|
||||
<span className="block text-2xl font-bold">Liquidity Provided Events</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto shadow-lg">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="bg-primary">Address</th>
|
||||
<th className="bg-primary">Amount of ETH In</th>
|
||||
<th className="bg-primary">Amount of Balloons In</th>
|
||||
<th className="bg-primary">Lİquidity Minted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!liquidityProvidedEvents || liquidityProvidedEvents.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center">
|
||||
No events found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
liquidityProvidedEvents?.map((event, index) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="text-center">
|
||||
<Address address={event.args.liquidityProvider} />
|
||||
</td>
|
||||
<td>{parseFloat(formatEther(event.args.ethInput || 0n)).toFixed(4)}</td>
|
||||
<td>{parseFloat(formatEther(event.args.tokensInput || 0n)).toFixed(4)}</td>
|
||||
<td>{parseFloat(formatEther(event.args.liquidityMinted || 0n)).toFixed(4)}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLiquidityRemovedEventsLoading ? (
|
||||
<div className="flex justify-center items-center mt-10">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-8 mb-8">
|
||||
<div className="text-center mb-4">
|
||||
<span className="block text-2xl font-bold">Liquidity Removed Events</span>
|
||||
</div>
|
||||
<div className="overflow-x-auto shadow-lg mb-5">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="bg-primary">Address</th>
|
||||
<th className="bg-primary">Amount of ETH Out</th>
|
||||
<th className="bg-primary">Amount of Balloons Out</th>
|
||||
<th className="bg-primary">Liquidity Withdrawn</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!liquidityRemovedEvents || liquidityRemovedEvents.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-center">
|
||||
No events found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
liquidityRemovedEvents?.map((event, index) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<td className="text-center">
|
||||
<Address address={event.args.liquidityRemover} />
|
||||
</td>
|
||||
<td>{parseFloat(formatEther(event.args.ethOutput || 0n)).toFixed(4)}</td>
|
||||
<td>{parseFloat(formatEther(event.args.tokensOutput || 0n)).toFixed(4)}</td>
|
||||
<td>{parseFloat(formatEther(event.args.liquidityWithdrawn || 0n)).toFixed(4)}</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Events;
|
||||
30
packages/nextjs/app/layout.tsx
Normal file
30
packages/nextjs/app/layout.tsx
Normal 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: "Dex | 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;
|
||||
16
packages/nextjs/app/not-found.tsx
Normal file
16
packages/nextjs/app/not-found.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex items-center h-full flex-1 justify-center bg-base-200">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold m-0 mb-1">404</h1>
|
||||
<h2 className="text-2xl font-semibold m-0">Page Not Found</h2>
|
||||
<p className="text-base-content/70 m-0 mb-4">The page you're looking for doesn't exist.</p>
|
||||
<Link href="/" className="btn btn-primary">
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
99
packages/nextjs/app/page.tsx
Normal file
99
packages/nextjs/app/page.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"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: Dex 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 pt-10">
|
||||
<div className="px-5">
|
||||
<h1 className="text-center mb-6">
|
||||
<span className="block text-2xl mb-2">SpeedRunEthereum</span>
|
||||
<span className="block text-4xl font-bold">Challenge: ⚖️ Build a DEX</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">
|
||||
This challenge will help you build/understand a simple decentralized exchange, with one token-pair
|
||||
(ERC20 BALLOONS ($BAL) and ETH). This repo is an updated version of the{" "}
|
||||
<a
|
||||
href="https://medium.com/@austin_48503/%EF%B8%8F-minimum-viable-exchange-d84f30bd0c90"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
original tutorial
|
||||
</a>{" "}
|
||||
and challenge repos before it. Please read the intro for a background on what we are building first!
|
||||
</p>
|
||||
<p className="text-center text-lg">
|
||||
🌟 The final deliverable is an app that allows users to seamlessly trade ERC20 BALLOONS ($BAL) with
|
||||
ETH in a decentralized manner. Users will be able to connect their wallets, view their token
|
||||
balances, and buy or sell their tokens according to a price formula! 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;
|
||||
80
packages/nextjs/components/Footer.tsx
Normal file
80
packages/nextjs/components/Footer.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
114
packages/nextjs/components/Header.tsx
Normal file
114
packages/nextjs/components/Header.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { Bars3Icon, BugAntIcon } from "@heroicons/react/24/outline";
|
||||
import { BoltIcon, KeyIcon } 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: "DEX",
|
||||
href: "/dex",
|
||||
icon: <KeyIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "Events",
|
||||
href: "/events",
|
||||
icon: <BoltIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "Debug Contracts",
|
||||
href: "/debug",
|
||||
icon: <BugAntIcon className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const HeaderMenuLinks = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
{menuLinks.map(({ label, href, icon }) => {
|
||||
const isActive = pathname === href;
|
||||
return (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
passHref
|
||||
className={`${
|
||||
isActive ? "bg-secondary shadow-md" : ""
|
||||
} hover:bg-secondary hover:shadow-md focus:!bg-secondary active:!text-neutral py-1.5 px-3 text-sm rounded-full gap-2 grid grid-flow-col`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Site header
|
||||
*/
|
||||
export const Header = () => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const isLocalNetwork = targetNetwork.id === hardhat.id;
|
||||
|
||||
const burgerMenuRef = useRef<HTMLDetailsElement>(null);
|
||||
useOutsideClick(burgerMenuRef, () => {
|
||||
burgerMenuRef?.current?.removeAttribute("open");
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="sticky lg:static top-0 navbar bg-base-100 min-h-0 shrink-0 justify-between z-20 shadow-md shadow-secondary px-0 sm:px-2">
|
||||
<div className="navbar-start w-auto lg:w-1/2">
|
||||
<details className="dropdown" ref={burgerMenuRef}>
|
||||
<summary className="ml-1 btn btn-ghost lg:hidden hover:bg-transparent">
|
||||
<Bars3Icon className="h-1/2" />
|
||||
</summary>
|
||||
<ul
|
||||
className="menu menu-compact dropdown-content mt-3 p-2 shadow-sm bg-base-100 rounded-box w-52"
|
||||
onClick={() => {
|
||||
burgerMenuRef?.current?.removeAttribute("open");
|
||||
}}
|
||||
>
|
||||
<HeaderMenuLinks />
|
||||
</ul>
|
||||
</details>
|
||||
<Link href="/" passHref className="hidden lg:flex items-center gap-2 ml-4 mr-6 shrink-0">
|
||||
<div className="flex relative w-10 h-10">
|
||||
<Image alt="SE2 logo" className="cursor-pointer" fill src="/logo.svg" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold leading-tight">SRE Challenges</span>
|
||||
<span className="text-xs">Build a DEX</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>
|
||||
);
|
||||
};
|
||||
61
packages/nextjs/components/ScaffoldEthAppWithProviders.tsx
Normal file
61
packages/nextjs/components/ScaffoldEthAppWithProviders.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
42
packages/nextjs/components/SwitchTheme.tsx
Normal file
42
packages/nextjs/components/SwitchTheme.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const SwitchTheme = ({ className }: { className?: string }) => {
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isDarkMode) {
|
||||
setTheme("light");
|
||||
return;
|
||||
}
|
||||
setTheme("dark");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className={`flex space-x-2 h-8 items-center justify-center text-sm ${className}`}>
|
||||
<input
|
||||
id="theme-toggle"
|
||||
type="checkbox"
|
||||
className="toggle bg-secondary toggle-primary hover:bg-accent transition-all"
|
||||
onChange={handleToggle}
|
||||
checked={isDarkMode}
|
||||
/>
|
||||
<label htmlFor="theme-toggle" className={`swap swap-rotate ${!isDarkMode ? "swap-active" : ""}`}>
|
||||
<SunIcon className="swap-on h-5 w-5" />
|
||||
<MoonIcon className="swap-off h-5 w-5" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
9
packages/nextjs/components/ThemeProvider.tsx
Normal file
9
packages/nextjs/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
};
|
||||
18
packages/nextjs/components/assets/BuidlGuidlLogo.tsx
Normal file
18
packages/nextjs/components/assets/BuidlGuidlLogo.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export const BuidlGuidlLogo = ({ className }: { className: string }) => {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="53"
|
||||
height="72"
|
||||
viewBox="0 0 53 72"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M25.9 17.434v15.638h3.927v9.04h9.718v-9.04h6.745v18.08l-10.607 19.88-12.11-.182-12.11.183L.856 51.152v-18.08h6.713v9.04h9.75v-9.04h4.329V2.46a2.126 2.126 0 0 1 4.047-.914c1.074.412 2.157 1.5 3.276 2.626 1.33 1.337 2.711 2.726 4.193 3.095 1.496.373 2.605-.026 3.855-.475 1.31-.47 2.776-.997 5.005-.747 1.67.197 2.557 1.289 3.548 2.509 1.317 1.623 2.82 3.473 6.599 3.752l-.024.017c-2.42 1.709-5.726 4.043-10.86 3.587-1.605-.139-2.736-.656-3.82-1.153-1.546-.707-2.997-1.37-5.59-.832-2.809.563-4.227 1.892-5.306 2.903-.236.221-.456.427-.67.606Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
187
packages/nextjs/components/scaffold-eth/Address/Address.tsx
Normal file
187
packages/nextjs/components/scaffold-eth/Address/Address.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
75
packages/nextjs/components/scaffold-eth/Balance.tsx
Normal file
75
packages/nextjs/components/scaffold-eth/Balance.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
17
packages/nextjs/components/scaffold-eth/BlockieAvatar.tsx
Normal file
17
packages/nextjs/components/scaffold-eth/BlockieAvatar.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { AvatarComponent } from "@rainbow-me/rainbowkit";
|
||||
import { blo } from "blo";
|
||||
|
||||
// Custom Avatar for RainbowKit
|
||||
export const BlockieAvatar: AvatarComponent = ({ address, ensImage, size }) => (
|
||||
// Don't want to use nextJS Image here (and adding remote patterns for the URL)
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={ensImage || blo(address as `0x${string}`)}
|
||||
width={size}
|
||||
height={size}
|
||||
alt={`${address} avatar`}
|
||||
/>
|
||||
);
|
||||
129
packages/nextjs/components/scaffold-eth/Faucet.tsx
Normal file
129
packages/nextjs/components/scaffold-eth/Faucet.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
73
packages/nextjs/components/scaffold-eth/FaucetButton.tsx
Normal file
73
packages/nextjs/components/scaffold-eth/FaucetButton.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { 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>
|
||||
);
|
||||
};
|
||||
120
packages/nextjs/components/scaffold-eth/Input/AddressInput.tsx
Normal file
120
packages/nextjs/components/scaffold-eth/Input/AddressInput.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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,
|
||||
isSuccess: isEnsAddressSuccess,
|
||||
} = 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,
|
||||
isSuccess: isEnsNameSuccess,
|
||||
} = 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 ||
|
||||
isEnsNameSuccess ||
|
||||
isEnsAddressSuccess ||
|
||||
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" />
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
28
packages/nextjs/components/scaffold-eth/Input/BytesInput.tsx
Normal file
28
packages/nextjs/components/scaffold-eth/Input/BytesInput.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
128
packages/nextjs/components/scaffold-eth/Input/EtherInput.tsx
Normal file
128
packages/nextjs/components/scaffold-eth/Input/EtherInput.tsx
Normal 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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
66
packages/nextjs/components/scaffold-eth/Input/InputBase.tsx
Normal file
66
packages/nextjs/components/scaffold-eth/Input/InputBase.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
9
packages/nextjs/components/scaffold-eth/Input/index.ts
Normal file
9
packages/nextjs/components/scaffold-eth/Input/index.ts
Normal 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";
|
||||
109
packages/nextjs/components/scaffold-eth/Input/utils.ts
Normal file
109
packages/nextjs/components/scaffold-eth/Input/utils.ts
Normal 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);
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { NetworkOptions } from "./NetworkOptions";
|
||||
import { getAddress } from "viem";
|
||||
import { Address } from "viem";
|
||||
import { useAccount, useDisconnect } from "wagmi";
|
||||
import {
|
||||
ArrowLeftOnRectangleIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EyeIcon,
|
||||
QrCodeIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { BlockieAvatar, isENS } from "~~/components/scaffold-eth";
|
||||
import { useCopyToClipboard, useOutsideClick } from "~~/hooks/scaffold-eth";
|
||||
import { getTargetNetworks } from "~~/utils/scaffold-eth";
|
||||
|
||||
const BURNER_WALLET_ID = "burnerWallet";
|
||||
|
||||
const allowedNetworks = getTargetNetworks();
|
||||
|
||||
type AddressInfoDropdownProps = {
|
||||
address: Address;
|
||||
blockExplorerAddressLink: string | undefined;
|
||||
displayName: string;
|
||||
ensAvatar?: string;
|
||||
};
|
||||
|
||||
export const AddressInfoDropdown = ({
|
||||
address,
|
||||
ensAvatar,
|
||||
displayName,
|
||||
blockExplorerAddressLink,
|
||||
}: AddressInfoDropdownProps) => {
|
||||
const { disconnect } = useDisconnect();
|
||||
const { connector } = useAccount();
|
||||
const checkSumAddress = getAddress(address);
|
||||
|
||||
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
|
||||
useCopyToClipboard();
|
||||
const [selectingNetwork, setSelectingNetwork] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDetailsElement>(null);
|
||||
|
||||
const closeDropdown = () => {
|
||||
setSelectingNetwork(false);
|
||||
dropdownRef.current?.removeAttribute("open");
|
||||
};
|
||||
|
||||
useOutsideClick(dropdownRef, closeDropdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<details ref={dropdownRef} className="dropdown dropdown-end leading-3">
|
||||
<summary className="btn btn-secondary btn-sm pl-0 pr-2 shadow-md dropdown-toggle gap-0 h-auto!">
|
||||
<BlockieAvatar address={checkSumAddress} size={30} ensImage={ensAvatar} />
|
||||
<span className="ml-2 mr-1">
|
||||
{isENS(displayName) ? displayName : checkSumAddress?.slice(0, 6) + "..." + checkSumAddress?.slice(-4)}
|
||||
</span>
|
||||
<ChevronDownIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
</summary>
|
||||
<ul className="dropdown-content menu z-2 p-2 mt-2 shadow-center shadow-accent bg-base-200 rounded-box gap-1">
|
||||
<NetworkOptions hidden={!selectingNetwork} />
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<div
|
||||
className="h-8 btn-sm rounded-xl! flex gap-3 py-3 cursor-pointer"
|
||||
onClick={() => copyAddressToClipboard(checkSumAddress)}
|
||||
>
|
||||
{isAddressCopiedToClipboard ? (
|
||||
<>
|
||||
<CheckCircleIcon className="text-xl font-normal h-6 w-4 ml-2 sm:ml-0" aria-hidden="true" />
|
||||
<span className="whitespace-nowrap">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DocumentDuplicateIcon className="text-xl font-normal h-6 w-4 ml-2 sm:ml-0" aria-hidden="true" />
|
||||
<span className="whitespace-nowrap">Copy address</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<label htmlFor="qrcode-modal" className="h-8 btn-sm rounded-xl! flex gap-3 py-3">
|
||||
<QrCodeIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<span className="whitespace-nowrap">View QR Code</span>
|
||||
</label>
|
||||
</li>
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<button className="h-8 btn-sm rounded-xl! flex gap-3 py-3" type="button">
|
||||
<ArrowTopRightOnSquareIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<a
|
||||
target="_blank"
|
||||
href={blockExplorerAddressLink}
|
||||
rel="noopener noreferrer"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
View on Block Explorer
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
{allowedNetworks.length > 1 ? (
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<button
|
||||
className="h-8 btn-sm rounded-xl! flex gap-3 py-3"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectingNetwork(true);
|
||||
}}
|
||||
>
|
||||
<ArrowsRightLeftIcon className="h-6 w-4 ml-2 sm:ml-0" /> <span>Switch Network</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{connector?.id === BURNER_WALLET_ID ? (
|
||||
<li>
|
||||
<label htmlFor="reveal-burner-pk-modal" className="h-8 btn-sm rounded-xl! flex gap-3 py-3 text-error">
|
||||
<EyeIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<span>Reveal Private Key</span>
|
||||
</label>
|
||||
</li>
|
||||
) : null}
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<button
|
||||
className="menu-item text-error h-8 btn-sm rounded-xl! flex gap-3 py-3"
|
||||
type="button"
|
||||
onClick={() => disconnect()}
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon className="h-6 w-4 ml-2 sm:ml-0" /> <span>Disconnect</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { Address as AddressType } from "viem";
|
||||
import { Address } from "~~/components/scaffold-eth";
|
||||
|
||||
type AddressQRCodeModalProps = {
|
||||
address: AddressType;
|
||||
modalId: string;
|
||||
};
|
||||
|
||||
export const AddressQRCodeModal = ({ address, modalId }: AddressQRCodeModalProps) => {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<input type="checkbox" id={`${modalId}`} className="modal-toggle" />
|
||||
<label htmlFor={`${modalId}`} className="modal cursor-pointer">
|
||||
<label className="modal-box relative">
|
||||
{/* dummy input to capture event onclick on modal box */}
|
||||
<input className="h-0 w-0 absolute top-0 left-0" />
|
||||
<label htmlFor={`${modalId}`} className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
|
||||
✕
|
||||
</label>
|
||||
<div className="space-y-3 py-6">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<QRCodeSVG value={address} size={256} />
|
||||
<Address address={address} format="long" disableAddressLink onlyEnsOrAddress />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { useAccount, useSwitchChain } from "wagmi";
|
||||
import { ArrowsRightLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { getNetworkColor } from "~~/hooks/scaffold-eth";
|
||||
import { getTargetNetworks } from "~~/utils/scaffold-eth";
|
||||
|
||||
const allowedNetworks = getTargetNetworks();
|
||||
|
||||
type NetworkOptionsProps = {
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export const NetworkOptions = ({ hidden = false }: NetworkOptionsProps) => {
|
||||
const { switchChain } = useSwitchChain();
|
||||
const { chain } = useAccount();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<>
|
||||
{allowedNetworks
|
||||
.filter(allowedNetwork => allowedNetwork.id !== chain?.id)
|
||||
.map(allowedNetwork => (
|
||||
<li key={allowedNetwork.id} className={hidden ? "hidden" : ""}>
|
||||
<button
|
||||
className="menu-item btn-sm rounded-xl! flex gap-3 py-3 whitespace-nowrap"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
switchChain?.({ chainId: allowedNetwork.id });
|
||||
}}
|
||||
>
|
||||
<ArrowsRightLeftIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<span>
|
||||
Switch to{" "}
|
||||
<span
|
||||
style={{
|
||||
color: getNetworkColor(allowedNetwork, isDarkMode),
|
||||
}}
|
||||
>
|
||||
{allowedNetwork.name}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useRef } from "react";
|
||||
import { rainbowkitBurnerWallet } from "burner-connector";
|
||||
import { ShieldExclamationIcon } from "@heroicons/react/24/outline";
|
||||
import { useCopyToClipboard } from "~~/hooks/scaffold-eth";
|
||||
import { getParsedError, notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
const BURNER_WALLET_PK_KEY = "burnerWallet.pk";
|
||||
|
||||
export const RevealBurnerPKModal = () => {
|
||||
const { copyToClipboard, isCopiedToClipboard } = useCopyToClipboard();
|
||||
const modalCheckboxRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleCopyPK = async () => {
|
||||
try {
|
||||
const storage = rainbowkitBurnerWallet.useSessionStorage ? sessionStorage : localStorage;
|
||||
const burnerPK = storage?.getItem(BURNER_WALLET_PK_KEY);
|
||||
if (!burnerPK) throw new Error("Burner wallet private key not found");
|
||||
await copyToClipboard(burnerPK);
|
||||
notification.success("Burner wallet private key copied to clipboard");
|
||||
} catch (e) {
|
||||
const parsedError = getParsedError(e);
|
||||
notification.error(parsedError);
|
||||
if (modalCheckboxRef.current) modalCheckboxRef.current.checked = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<input type="checkbox" id="reveal-burner-pk-modal" className="modal-toggle" ref={modalCheckboxRef} />
|
||||
<label htmlFor="reveal-burner-pk-modal" className="modal cursor-pointer">
|
||||
<label className="modal-box relative">
|
||||
{/* dummy input to capture event onclick on modal box */}
|
||||
<input className="h-0 w-0 absolute top-0 left-0" />
|
||||
<label htmlFor="reveal-burner-pk-modal" className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
|
||||
✕
|
||||
</label>
|
||||
<div>
|
||||
<p className="text-lg font-semibold m-0 p-0">Copy Burner Wallet Private Key</p>
|
||||
<div role="alert" className="alert alert-warning mt-4">
|
||||
<ShieldExclamationIcon className="h-6 w-6" />
|
||||
<span className="font-semibold">
|
||||
Burner wallets are intended for local development only and are not safe for storing real funds.
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
Your Private Key provides <strong>full access</strong> to your entire wallet and funds. This is
|
||||
currently stored <strong>temporarily</strong> in your browser.
|
||||
</p>
|
||||
<button className="btn btn-outline btn-error" onClick={handleCopyPK} disabled={isCopiedToClipboard}>
|
||||
Copy Private Key To Clipboard
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user