Initial commit with 🏗️ Scaffold-ETH 2 @ 1.0.2

This commit is contained in:
han
2026-01-21 20:45:23 +07:00
commit e7b2a69f2a
156 changed files with 29196 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
# Template for Hardhat environment variables.
# To use this template, copy this file, rename it .env, and fill in the values.
# If not set, we provide default values (check `hardhat.config.ts`) so developers can start prototyping out of the box,
# but we recommend getting your own API Keys for Production Apps.
# To access the values stored in this .env file you can use: process.env.VARIABLENAME
ALCHEMY_API_KEY=
ETHERSCAN_V2_API_KEY=
# Don't fill this value manually, run yarn generate to generate a new account or yarn account:import to import an existing PK.
DEPLOYER_PRIVATE_KEY_ENCRYPTED=

30
packages/hardhat/.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# dependencies
node_modules
# env files
.env
# coverage
coverage
coverage.json
# typechain
typechain
typechain-types
# hardhat files
cache
artifacts
# zkSync files
artifacts-zk
cache-zk
# deployments
deployments/localhost
# typescript
*.tsbuildinfo
# other
temp

View File

@@ -0,0 +1,18 @@
{
"plugins": ["prettier-plugin-solidity"],
"arrowParens": "avoid",
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "all",
"overrides": [
{
"files": "*.sol",
"options": {
"printWidth": 120,
"tabWidth": 4,
"singleQuote": false,
"bracketSpacing": true
}
}
]
}

View File

@@ -0,0 +1,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!
}
}

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

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

View File

@@ -0,0 +1,44 @@
import { defineConfig, globalIgnores } from "eslint/config";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import prettierPlugin from "eslint-plugin-prettier";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
export default defineConfig([
globalIgnores(["**/artifacts", "**/cache", "**/contracts", "**/node_modules/", "**/typechain-types", "**/*.json"]),
{
extends: compat.extends("plugin:@typescript-eslint/recommended", "prettier"),
plugins: {
prettier: prettierPlugin,
},
languageOptions: {
globals: {
...globals.node,
},
parser: tsParser,
},
rules: {
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "off",
"prettier/prettier": [
"warn",
{
endOfLine: "auto",
},
],
},
},
]);

View File

@@ -0,0 +1,152 @@
import * as dotenv from "dotenv";
dotenv.config();
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-ethers";
import "@nomicfoundation/hardhat-chai-matchers";
import "@typechain/hardhat";
import "hardhat-gas-reporter";
import "solidity-coverage";
import "@nomicfoundation/hardhat-verify";
import "hardhat-deploy";
import "hardhat-deploy-ethers";
import { task } from "hardhat/config";
import generateTsAbis from "./scripts/generateTsAbis";
// If not set, it uses ours Alchemy's default API key.
// You can get your own at https://dashboard.alchemyapi.io
const providerApiKey = process.env.ALCHEMY_API_KEY || "oKxs-03sij-U_N0iOlrSsZFr29-IqbuF";
// If not set, it uses the hardhat account 0 private key.
// You can generate a random account with `yarn generate` or `yarn account:import` to import your existing PK
const deployerPrivateKey =
process.env.__RUNTIME_DEPLOYER_PRIVATE_KEY ?? "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
// If not set, it uses our block explorers default API keys.
const etherscanApiKey = process.env.ETHERSCAN_V2_API_KEY || "DNXJA8RX2Q3VZ4URQIWP7Z68CJXQZSC6AW";
const config: HardhatUserConfig = {
solidity: {
compilers: [
{
version: "0.8.20",
settings: {
optimizer: {
enabled: true,
// https://docs.soliditylang.org/en/latest/using-the-compiler.html#optimizer-options
runs: 200,
},
},
},
],
},
defaultNetwork: "localhost",
namedAccounts: {
deployer: {
// By default, it will take the first Hardhat account as the deployer
default: 0,
},
},
networks: {
// View the networks that are pre-configured.
// If the network you are looking for is not here you can add new network settings
hardhat: {
forking: {
url: `https://eth-mainnet.alchemyapi.io/v2/${providerApiKey}`,
enabled: process.env.MAINNET_FORKING_ENABLED === "true",
},
},
mainnet: {
url: "https://mainnet.rpc.buidlguidl.com",
accounts: [deployerPrivateKey],
},
sepolia: {
url: `https://eth-sepolia.g.alchemy.com/v2/${providerApiKey}`,
accounts: [deployerPrivateKey],
},
arbitrum: {
url: `https://arb-mainnet.g.alchemy.com/v2/${providerApiKey}`,
accounts: [deployerPrivateKey],
},
arbitrumSepolia: {
url: `https://arb-sepolia.g.alchemy.com/v2/${providerApiKey}`,
accounts: [deployerPrivateKey],
},
optimism: {
url: `https://opt-mainnet.g.alchemy.com/v2/${providerApiKey}`,
accounts: [deployerPrivateKey],
},
optimismSepolia: {
url: `https://opt-sepolia.g.alchemy.com/v2/${providerApiKey}`,
accounts: [deployerPrivateKey],
},
polygon: {
url: `https://polygon-mainnet.g.alchemy.com/v2/${providerApiKey}`,
accounts: [deployerPrivateKey],
},
polygonAmoy: {
url: `https://polygon-amoy.g.alchemy.com/v2/${providerApiKey}`,
accounts: [deployerPrivateKey],
},
polygonZkEvm: {
url: `https://polygonzkevm-mainnet.g.alchemy.com/v2/${providerApiKey}`,
accounts: [deployerPrivateKey],
},
polygonZkEvmCardona: {
url: `https://polygonzkevm-cardona.g.alchemy.com/v2/${providerApiKey}`,
accounts: [deployerPrivateKey],
},
gnosis: {
url: "https://rpc.gnosischain.com",
accounts: [deployerPrivateKey],
},
chiado: {
url: "https://rpc.chiadochain.net",
accounts: [deployerPrivateKey],
},
base: {
url: "https://mainnet.base.org",
accounts: [deployerPrivateKey],
},
baseSepolia: {
url: "https://sepolia.base.org",
accounts: [deployerPrivateKey],
},
scrollSepolia: {
url: "https://sepolia-rpc.scroll.io",
accounts: [deployerPrivateKey],
},
scroll: {
url: "https://rpc.scroll.io",
accounts: [deployerPrivateKey],
},
celo: {
url: "https://forno.celo.org",
accounts: [deployerPrivateKey],
},
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;

View File

@@ -0,0 +1,63 @@
{
"name": "@se-2/hardhat",
"version": "0.0.1",
"scripts": {
"account": "hardhat run scripts/listAccount.ts",
"account:generate": "hardhat run scripts/generateAccount.ts",
"account:import": "hardhat run scripts/importAccount.ts",
"account:reveal-pk": "hardhat run scripts/revealPK.ts",
"chain": "hardhat node --network hardhat --no-deploy",
"check-types": "tsc --noEmit --incremental",
"clean": "hardhat clean",
"compile": "hardhat compile",
"deploy": "ts-node scripts/runHardhatDeployWithPK.ts",
"flatten": "hardhat flatten",
"fork": "MAINNET_FORKING_ENABLED=true hardhat node --network hardhat --no-deploy",
"format": "prettier --write './**/*.(ts|sol)'",
"generate": "yarn account:generate",
"hardhat-verify": "hardhat verify",
"lint": "eslint",
"lint-staged": "eslint",
"test": "REPORT_GAS=true hardhat test --network hardhat",
"verify": "hardhat etherscan-verify"
},
"dependencies": {
"@inquirer/password": "^4.0.2",
"@openzeppelin/contracts": "~5.0.2",
"@typechain/ethers-v6": "~0.5.1",
"dotenv": "~16.4.5",
"envfile": "~7.1.0",
"qrcode": "~1.5.4"
},
"devDependencies": {
"@ethersproject/abi": "~5.7.0",
"@ethersproject/providers": "~5.7.2",
"@nomicfoundation/hardhat-chai-matchers": "~2.0.7",
"@nomicfoundation/hardhat-ethers": "~3.0.8",
"@nomicfoundation/hardhat-network-helpers": "~1.0.11",
"@nomicfoundation/hardhat-verify": "~2.0.10",
"@typechain/ethers-v5": "~11.1.2",
"@typechain/hardhat": "~9.1.0",
"@types/eslint": "~9.6.1",
"@types/mocha": "~10.0.10",
"@types/prettier": "~3.0.0",
"@types/qrcode": "~1.5.5",
"@typescript-eslint/eslint-plugin": "~8.27.0",
"@typescript-eslint/parser": "~8.27.0",
"chai": "~4.5.0",
"eslint": "~9.23.0",
"eslint-config-prettier": "~10.1.1",
"eslint-plugin-prettier": "~5.2.4",
"ethers": "~6.13.2",
"hardhat": "~2.22.10",
"hardhat-deploy": "^1.0.4",
"hardhat-deploy-ethers": "~0.4.2",
"hardhat-gas-reporter": "~2.2.1",
"prettier": "^3.5.3",
"prettier-plugin-solidity": "~1.4.1",
"solidity-coverage": "~0.8.13",
"ts-node": "~10.9.1",
"typechain": "~8.3.2",
"typescript": "^5.8.2"
}
}

View File

@@ -0,0 +1,58 @@
import { ethers } from "ethers";
import { parse, stringify } from "envfile";
import * as fs from "fs";
import password from "@inquirer/password";
const envFilePath = "./.env";
const getValidatedPassword = async () => {
while (true) {
const pass = await password({ message: "Enter a password to encrypt your private key:" });
const confirmation = await password({ message: "Confirm password:" });
if (pass === confirmation) {
return pass;
}
console.log("❌ Passwords don't match. Please try again.");
}
};
const setNewEnvConfig = async (existingEnvConfig = {}) => {
console.log("👛 Generating new Wallet\n");
const randomWallet = ethers.Wallet.createRandom();
const pass = await getValidatedPassword();
const encryptedJson = await randomWallet.encrypt(pass);
const newEnvConfig = {
...existingEnvConfig,
DEPLOYER_PRIVATE_KEY_ENCRYPTED: encryptedJson,
};
// Store in .env
fs.writeFileSync(envFilePath, stringify(newEnvConfig));
console.log("\n📄 Encrypted Private Key saved to packages/hardhat/.env file");
console.log("🪄 Generated wallet address:", randomWallet.address, "\n");
console.log("⚠️ Make sure to remember your password! You'll need it to decrypt the private key.");
};
async function main() {
if (!fs.existsSync(envFilePath)) {
// No .env file yet.
await setNewEnvConfig();
return;
}
const existingEnvConfig = parse(fs.readFileSync(envFilePath).toString());
if (existingEnvConfig.DEPLOYER_PRIVATE_KEY_ENCRYPTED) {
console.log("⚠️ You already have a deployer account. Check the packages/hardhat/.env file");
return;
}
await setNewEnvConfig(existingEnvConfig);
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,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;

View File

@@ -0,0 +1,72 @@
import { ethers } from "ethers";
import { parse, stringify } from "envfile";
import * as fs from "fs";
import password from "@inquirer/password";
const envFilePath = "./.env";
const getValidatedPassword = async () => {
while (true) {
const pass = await password({ message: "Enter a password to encrypt your private key:" });
const confirmation = await password({ message: "Confirm password:" });
if (pass === confirmation) {
return pass;
}
console.log("❌ Passwords don't match. Please try again.");
}
};
const getWalletFromPrivateKey = async () => {
while (true) {
const privateKey = await password({ message: "Paste your private key:" });
try {
const wallet = new ethers.Wallet(privateKey);
return wallet;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
console.log("❌ Invalid private key format. Please try again.");
}
}
};
const setNewEnvConfig = async (existingEnvConfig = {}) => {
console.log("👛 Importing Wallet\n");
const wallet = await getWalletFromPrivateKey();
const pass = await getValidatedPassword();
const encryptedJson = await wallet.encrypt(pass);
const newEnvConfig = {
...existingEnvConfig,
DEPLOYER_PRIVATE_KEY_ENCRYPTED: encryptedJson,
};
// Store in .env
fs.writeFileSync(envFilePath, stringify(newEnvConfig));
console.log("\n📄 Encrypted Private Key saved to packages/hardhat/.env file");
console.log("🪄 Imported wallet address:", wallet.address, "\n");
console.log("⚠️ Make sure to remember your password! You'll need it to decrypt the private key.");
};
async function main() {
if (!fs.existsSync(envFilePath)) {
// No .env file yet.
await setNewEnvConfig();
return;
}
const existingEnvConfig = parse(fs.readFileSync(envFilePath).toString());
if (existingEnvConfig.DEPLOYER_PRIVATE_KEY_ENCRYPTED) {
console.log("⚠️ You already have a deployer account. Check the packages/hardhat/.env file");
return;
}
await setNewEnvConfig(existingEnvConfig);
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,52 @@
import * as dotenv from "dotenv";
dotenv.config();
import { ethers, Wallet } from "ethers";
import QRCode from "qrcode";
import { config } from "hardhat";
import password from "@inquirer/password";
async function main() {
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
if (!encryptedKey) {
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
return;
}
const pass = await password({ message: "Enter your password to decrypt the private key:" });
let wallet: Wallet;
try {
wallet = (await Wallet.fromEncryptedJson(encryptedKey, pass)) as Wallet;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
console.log("❌ Failed to decrypt private key. Wrong password?");
return;
}
const address = wallet.address;
console.log(await QRCode.toString(address, { type: "terminal", small: true }));
console.log("Public address:", address, "\n");
// Balance on each network
const availableNetworks = config.networks;
for (const networkName in availableNetworks) {
try {
const network = availableNetworks[networkName];
if (!("url" in network)) continue;
const provider = new ethers.JsonRpcProvider(network.url);
await provider._detectNetwork();
const balance = await provider.getBalance(address);
console.log("--", networkName, "-- 📡");
console.log(" balance:", +ethers.formatEther(balance));
console.log(" nonce:", +(await provider.getTransactionCount(address)));
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
console.log("Can't connect to network", networkName);
}
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,31 @@
import * as dotenv from "dotenv";
dotenv.config();
import { Wallet } from "ethers";
import password from "@inquirer/password";
async function main() {
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
if (!encryptedKey) {
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
return;
}
console.log("👀 This will reveal your private key on the console.\n");
const pass = await password({ message: "Enter your password to decrypt the private key:" });
let wallet: Wallet;
try {
wallet = (await Wallet.fromEncryptedJson(encryptedKey, pass)) as Wallet;
} catch {
console.log("❌ Failed to decrypt private key. Wrong password?");
return;
}
console.log("\n🔑 Private key:", wallet.privateKey);
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -0,0 +1,58 @@
import * as dotenv from "dotenv";
dotenv.config();
import { Wallet } from "ethers";
import password from "@inquirer/password";
import { spawn } from "child_process";
import { config } from "hardhat";
/**
* Unencrypts the private key and runs the hardhat deploy command
*/
async function main() {
const networkIndex = process.argv.indexOf("--network");
const networkName = networkIndex !== -1 ? process.argv[networkIndex + 1] : config.defaultNetwork;
if (networkName === "localhost" || networkName === "hardhat") {
// Deploy command on the localhost network
const hardhat = spawn("hardhat", ["deploy", ...process.argv.slice(2)], {
stdio: "inherit",
env: process.env,
shell: process.platform === "win32",
});
hardhat.on("exit", code => {
process.exit(code || 0);
});
return;
}
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
if (!encryptedKey) {
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
return;
}
const pass = await password({ message: "Enter password to decrypt private key:" });
try {
const wallet = await Wallet.fromEncryptedJson(encryptedKey, pass);
process.env.__RUNTIME_DEPLOYER_PRIVATE_KEY = wallet.privateKey;
const hardhat = spawn("hardhat", ["deploy", ...process.argv.slice(2)], {
stdio: "inherit",
env: process.env,
shell: process.platform === "win32",
});
hardhat.on("exit", code => {
process.exit(code || 0);
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
console.error("Failed to decrypt private key. Wrong password?");
process.exit(1);
}
}
main().catch(console.error);

View File

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

View File

@@ -0,0 +1,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 -----------------
});

View File

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

View File

@@ -0,0 +1,14 @@
# Template for NextJS environment variables.
# For local development, copy this file, rename it to .env.local, and fill in the values.
# When deploying live, you'll need to store the vars in Vercel/System config.
# If not set, we provide default values (check `scaffold.config.ts`) so developers can start prototyping out of the box,
# but we recommend getting your own API Keys for Production Apps.
# To access the values stored in this env file you can use: process.env.VARIABLENAME
# You'll need to prefix the variables names with NEXT_PUBLIC_ if you want to access them on the client side.
# More info: https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables
NEXT_PUBLIC_ALCHEMY_API_KEY=
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=

38
packages/nextjs/.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
.vercel
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# typescript
*.tsbuildinfo
ipfs-upload.config.json

View File

@@ -0,0 +1,9 @@
module.exports = {
arrowParens: "avoid",
printWidth: 120,
tabWidth: 2,
trailingComma: "all",
importOrder: ["^react$", "^next/(.*)$", "<THIRD_PARTY_MODULES>", "^@heroicons/(.*)$", "^~~/(.*)$"],
importOrderSortSpecifiers: true,
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
};

View File

@@ -0,0 +1,27 @@
type AddressCodeTabProps = {
bytecode: string;
assembly: string;
};
export const AddressCodeTab = ({ bytecode, assembly }: AddressCodeTabProps) => {
const formattedAssembly = Array.from(assembly.matchAll(/\w+( 0x[a-fA-F0-9]+)?/g))
.map(it => it[0])
.join("\n");
return (
<div className="flex flex-col gap-3 p-4">
Bytecode
<div className="mockup-code -indent-5 overflow-y-auto max-h-[500px]">
<pre className="px-5">
<code className="whitespace-pre-wrap overflow-auto break-words">{bytecode}</code>
</pre>
</div>
Opcodes
<div className="mockup-code -indent-5 overflow-y-auto max-h-[500px]">
<pre className="px-5">
<code>{formattedAssembly}</code>
</pre>
</div>
</div>
);
};

View File

@@ -0,0 +1,36 @@
import { BackButton } from "./BackButton";
import { ContractTabs } from "./ContractTabs";
import { Address as AddressType } from "viem";
import { Address, Balance } from "~~/components/scaffold-eth";
export const AddressComponent = ({
address,
contractData,
}: {
address: AddressType;
contractData: { bytecode: string; assembly: string } | null;
}) => {
return (
<div className="m-10 mb-20">
<div className="flex justify-start mb-5">
<BackButton />
</div>
<div className="col-span-5 grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-10">
<div className="col-span-1 flex flex-col">
<div className="bg-base-100 border-base-300 border shadow-md shadow-secondary rounded-3xl px-6 lg:px-8 mb-6 space-y-1 py-4 overflow-x-auto">
<div className="flex">
<div className="flex flex-col gap-1">
<Address address={address} format="long" onlyEnsOrAddress />
<div className="flex gap-1 items-center">
<span className="font-bold text-sm">Balance:</span>
<Balance address={address} className="text" />
</div>
</div>
</div>
</div>
</div>
</div>
<ContractTabs address={address} contractData={contractData} />
</div>
);
};

View File

@@ -0,0 +1,21 @@
import { Address } from "viem";
import { useContractLogs } from "~~/hooks/scaffold-eth";
import { replacer } from "~~/utils/scaffold-eth/common";
export const AddressLogsTab = ({ address }: { address: Address }) => {
const contractLogs = useContractLogs(address);
return (
<div className="flex flex-col gap-3 p-4">
<div className="mockup-code overflow-auto max-h-[500px]">
<pre className="px-5 whitespace-pre-wrap break-words">
{contractLogs.map((log, i) => (
<div key={i}>
<strong>Log:</strong> {JSON.stringify(log, replacer, 2)}
</div>
))}
</pre>
</div>
</div>
);
};

View File

@@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { Address, createPublicClient, http, toHex } from "viem";
import { hardhat } from "viem/chains";
const publicClient = createPublicClient({
chain: hardhat,
transport: http(),
});
export const AddressStorageTab = ({ address }: { address: Address }) => {
const [storage, setStorage] = useState<string[]>([]);
useEffect(() => {
const fetchStorage = async () => {
try {
const storageData = [];
let idx = 0;
while (true) {
const storageAtPosition = await publicClient.getStorageAt({
address: address,
slot: toHex(idx),
});
if (storageAtPosition === "0x" + "0".repeat(64)) break;
if (storageAtPosition) {
storageData.push(storageAtPosition);
}
idx++;
}
setStorage(storageData);
} catch (error) {
console.error("Failed to fetch storage:", error);
}
};
fetchStorage();
}, [address]);
return (
<div className="flex flex-col gap-3 p-4">
{storage.length > 0 ? (
<div className="mockup-code overflow-auto max-h-[500px]">
<pre className="px-5 whitespace-pre-wrap break-words">
{storage.map((data, i) => (
<div key={i}>
<strong>Storage Slot {i}:</strong> {data}
</div>
))}
</pre>
</div>
) : (
<div className="text-lg">This contract does not have any variables.</div>
)}
</div>
);
};

View File

@@ -0,0 +1,12 @@
"use client";
import { useRouter } from "next/navigation";
export const BackButton = () => {
const router = useRouter();
return (
<button className="btn btn-sm btn-primary" onClick={() => router.back()}>
Back
</button>
);
};

View File

@@ -0,0 +1,102 @@
"use client";
import { useEffect, useState } from "react";
import { AddressCodeTab } from "./AddressCodeTab";
import { AddressLogsTab } from "./AddressLogsTab";
import { AddressStorageTab } from "./AddressStorageTab";
import { PaginationButton } from "./PaginationButton";
import { TransactionsTable } from "./TransactionsTable";
import { Address, createPublicClient, http } from "viem";
import { hardhat } from "viem/chains";
import { useFetchBlocks } from "~~/hooks/scaffold-eth";
type AddressCodeTabProps = {
bytecode: string;
assembly: string;
};
type PageProps = {
address: Address;
contractData: AddressCodeTabProps | null;
};
const publicClient = createPublicClient({
chain: hardhat,
transport: http(),
});
export const ContractTabs = ({ address, contractData }: PageProps) => {
const { blocks, transactionReceipts, currentPage, totalBlocks, setCurrentPage } = useFetchBlocks();
const [activeTab, setActiveTab] = useState("transactions");
const [isContract, setIsContract] = useState(false);
useEffect(() => {
const checkIsContract = async () => {
const contractCode = await publicClient.getBytecode({ address: address });
setIsContract(contractCode !== undefined && contractCode !== "0x");
};
checkIsContract();
}, [address]);
const filteredBlocks = blocks.filter(block =>
block.transactions.some(tx => {
if (typeof tx === "string") {
return false;
}
return tx.from.toLowerCase() === address.toLowerCase() || tx.to?.toLowerCase() === address.toLowerCase();
}),
);
return (
<>
{isContract && (
<div role="tablist" className="tabs tabs-lift">
<button
role="tab"
className={`tab ${activeTab === "transactions" ? "tab-active" : ""}`}
onClick={() => setActiveTab("transactions")}
>
Transactions
</button>
<button
role="tab"
className={`tab ${activeTab === "code" ? "tab-active" : ""}`}
onClick={() => setActiveTab("code")}
>
Code
</button>
<button
role="tab"
className={`tab ${activeTab === "storage" ? "tab-active" : ""}`}
onClick={() => setActiveTab("storage")}
>
Storage
</button>
<button
role="tab"
className={`tab ${activeTab === "logs" ? "tab-active" : ""}`}
onClick={() => setActiveTab("logs")}
>
Logs
</button>
</div>
)}
{activeTab === "transactions" && (
<div className="pt-4">
<TransactionsTable blocks={filteredBlocks} transactionReceipts={transactionReceipts} />
<PaginationButton
currentPage={currentPage}
totalItems={Number(totalBlocks)}
setCurrentPage={setCurrentPage}
/>
</div>
)}
{activeTab === "code" && contractData && (
<AddressCodeTab bytecode={contractData.bytecode} assembly={contractData.assembly} />
)}
{activeTab === "storage" && <AddressStorageTab address={address} />}
{activeTab === "logs" && <AddressLogsTab address={address} />}
</>
);
};

View File

@@ -0,0 +1,39 @@
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
type PaginationButtonProps = {
currentPage: number;
totalItems: number;
setCurrentPage: (page: number) => void;
};
const ITEMS_PER_PAGE = 20;
export const PaginationButton = ({ currentPage, totalItems, setCurrentPage }: PaginationButtonProps) => {
const isPrevButtonDisabled = currentPage === 0;
const isNextButtonDisabled = currentPage + 1 >= Math.ceil(totalItems / ITEMS_PER_PAGE);
const prevButtonClass = isPrevButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
const nextButtonClass = isNextButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
if (isNextButtonDisabled && isPrevButtonDisabled) return null;
return (
<div className="mt-5 justify-end flex gap-3 mx-5">
<button
className={`btn btn-sm ${prevButtonClass}`}
disabled={isPrevButtonDisabled}
onClick={() => setCurrentPage(currentPage - 1)}
>
<ArrowLeftIcon className="h-4 w-4" />
</button>
<span className="self-center text-primary-content font-medium">Page {currentPage + 1}</span>
<button
className={`btn btn-sm ${nextButtonClass}`}
disabled={isNextButtonDisabled}
onClick={() => setCurrentPage(currentPage + 1)}
>
<ArrowRightIcon className="h-4 w-4" />
</button>
</div>
);
};

View File

@@ -0,0 +1,49 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { isAddress, isHex } from "viem";
import { hardhat } from "viem/chains";
import { usePublicClient } from "wagmi";
export const SearchBar = () => {
const [searchInput, setSearchInput] = useState("");
const router = useRouter();
const client = usePublicClient({ chainId: hardhat.id });
const handleSearch = async (event: React.FormEvent) => {
event.preventDefault();
if (isHex(searchInput)) {
try {
const tx = await client?.getTransaction({ hash: searchInput });
if (tx) {
router.push(`/blockexplorer/transaction/${searchInput}`);
return;
}
} catch (error) {
console.error("Failed to fetch transaction:", error);
}
}
if (isAddress(searchInput)) {
router.push(`/blockexplorer/address/${searchInput}`);
return;
}
};
return (
<form onSubmit={handleSearch} className="flex items-center justify-end mb-5 space-x-3 mx-5">
<input
className="border-primary bg-base-100 text-base-content placeholder:text-base-content/50 p-2 mr-2 w-full md:w-1/2 lg:w-1/3 rounded-md shadow-md focus:outline-hidden focus:ring-2 focus:ring-accent"
type="text"
value={searchInput}
placeholder="Search by hash or address"
onChange={e => setSearchInput(e.target.value)}
/>
<button className="btn btn-sm btn-primary" type="submit">
Search
</button>
</form>
);
};

View File

@@ -0,0 +1,28 @@
import Link from "next/link";
import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
import { useCopyToClipboard } from "~~/hooks/scaffold-eth/useCopyToClipboard";
export const TransactionHash = ({ hash }: { hash: string }) => {
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
useCopyToClipboard();
return (
<div className="flex items-center">
<Link href={`/blockexplorer/transaction/${hash}`}>
{hash?.substring(0, 6)}...{hash?.substring(hash.length - 4)}
</Link>
{isAddressCopiedToClipboard ? (
<CheckCircleIcon
className="ml-1.5 text-xl font-normal text-base-content h-5 w-5 cursor-pointer"
aria-hidden="true"
/>
) : (
<DocumentDuplicateIcon
className="ml-1.5 text-xl font-normal h-5 w-5 cursor-pointer"
aria-hidden="true"
onClick={() => copyAddressToClipboard(hash)}
/>
)}
</div>
);
};

View File

@@ -0,0 +1,71 @@
import { TransactionHash } from "./TransactionHash";
import { formatEther } from "viem";
import { Address } from "~~/components/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { TransactionWithFunction } from "~~/utils/scaffold-eth";
import { TransactionsTableProps } from "~~/utils/scaffold-eth/";
export const TransactionsTable = ({ blocks, transactionReceipts }: TransactionsTableProps) => {
const { targetNetwork } = useTargetNetwork();
return (
<div className="flex justify-center px-4 md:px-0">
<div className="overflow-x-auto w-full shadow-2xl rounded-xl">
<table className="table text-xl bg-base-100 table-zebra w-full md:table-md table-sm">
<thead>
<tr className="rounded-xl text-sm text-base-content">
<th className="bg-primary">Transaction Hash</th>
<th className="bg-primary">Function Called</th>
<th className="bg-primary">Block Number</th>
<th className="bg-primary">Time Mined</th>
<th className="bg-primary">From</th>
<th className="bg-primary">To</th>
<th className="bg-primary text-end">Value ({targetNetwork.nativeCurrency.symbol})</th>
</tr>
</thead>
<tbody>
{blocks.map(block =>
(block.transactions as TransactionWithFunction[]).map(tx => {
const receipt = transactionReceipts[tx.hash];
const timeMined = new Date(Number(block.timestamp) * 1000).toLocaleString();
const functionCalled = tx.input.substring(0, 10);
return (
<tr key={tx.hash} className="hover text-sm">
<td className="w-1/12 md:py-4">
<TransactionHash hash={tx.hash} />
</td>
<td className="w-2/12 md:py-4">
{tx.functionName === "0x" ? "" : <span className="mr-1">{tx.functionName}</span>}
{functionCalled !== "0x" && (
<span className="badge badge-primary font-bold text-xs">{functionCalled}</span>
)}
</td>
<td className="w-1/12 md:py-4">{block.number?.toString()}</td>
<td className="w-2/12 md:py-4">{timeMined}</td>
<td className="w-2/12 md:py-4">
<Address address={tx.from} size="sm" onlyEnsOrAddress />
</td>
<td className="w-2/12 md:py-4">
{!receipt?.contractAddress ? (
tx.to && <Address address={tx.to} size="sm" onlyEnsOrAddress />
) : (
<div className="relative">
<Address address={receipt.contractAddress} size="sm" onlyEnsOrAddress />
<small className="absolute top-4 left-4">(Contract Creation)</small>
</div>
)}
</td>
<td className="text-right md:py-4">
{formatEther(tx.value)} {targetNetwork.nativeCurrency.symbol}
</td>
</tr>
);
}),
)}
</tbody>
</table>
</div>
</div>
);
};

View File

@@ -0,0 +1,7 @@
export * from "./SearchBar";
export * from "./BackButton";
export * from "./AddressCodeTab";
export * from "./TransactionHash";
export * from "./ContractTabs";
export * from "./PaginationButton";
export * from "./TransactionsTable";

View File

@@ -0,0 +1,101 @@
import fs from "fs";
import path from "path";
import { Address } from "viem";
import { hardhat } from "viem/chains";
import { AddressComponent } from "~~/app/blockexplorer/_components/AddressComponent";
import deployedContracts from "~~/contracts/deployedContracts";
import { isZeroAddress } from "~~/utils/scaffold-eth/common";
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
type PageProps = {
params: Promise<{ address: Address }>;
};
async function fetchByteCodeAndAssembly(buildInfoDirectory: string, contractPath: string) {
const buildInfoFiles = fs.readdirSync(buildInfoDirectory);
let bytecode = "";
let assembly = "";
for (let i = 0; i < buildInfoFiles.length; i++) {
const filePath = path.join(buildInfoDirectory, buildInfoFiles[i]);
const buildInfo = JSON.parse(fs.readFileSync(filePath, "utf8"));
if (buildInfo.output.contracts[contractPath]) {
for (const contract in buildInfo.output.contracts[contractPath]) {
bytecode = buildInfo.output.contracts[contractPath][contract].evm.bytecode.object;
assembly = buildInfo.output.contracts[contractPath][contract].evm.bytecode.opcodes;
break;
}
}
if (bytecode && assembly) {
break;
}
}
return { bytecode, assembly };
}
const getContractData = async (address: Address) => {
const contracts = deployedContracts as GenericContractsDeclaration | null;
const chainId = hardhat.id;
if (!contracts || !contracts[chainId] || Object.keys(contracts[chainId]).length === 0) {
return null;
}
let contractPath = "";
const buildInfoDirectory = path.join(
__dirname,
"..",
"..",
"..",
"..",
"..",
"..",
"..",
"hardhat",
"artifacts",
"build-info",
);
if (!fs.existsSync(buildInfoDirectory)) {
throw new Error(`Directory ${buildInfoDirectory} not found.`);
}
const deployedContractsOnChain = contracts[chainId];
for (const [contractName, contractInfo] of Object.entries(deployedContractsOnChain)) {
if (contractInfo.address.toLowerCase() === address.toLowerCase()) {
contractPath = `contracts/${contractName}.sol`;
break;
}
}
if (!contractPath) {
// No contract found at this address
return null;
}
const { bytecode, assembly } = await fetchByteCodeAndAssembly(buildInfoDirectory, contractPath);
return { bytecode, assembly };
};
export function generateStaticParams() {
// An workaround to enable static exports in Next.js, generating single dummy page.
return [{ address: "0x0000000000000000000000000000000000000000" }];
}
const AddressPage = async (props: PageProps) => {
const params = await props.params;
const address = params?.address as Address;
if (isZeroAddress(address)) return null;
const contractData: { bytecode: string; assembly: string } | null = await getContractData(address);
return <AddressComponent address={address} contractData={contractData} />;
};
export default AddressPage;

View File

@@ -0,0 +1,12 @@
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
export const metadata = getMetadata({
title: "Block Explorer",
description: "Block Explorer created with 🏗 Scaffold-ETH 2",
});
const BlockExplorerLayout = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
export default BlockExplorerLayout;

View File

@@ -0,0 +1,83 @@
"use client";
import { useEffect, useState } from "react";
import { PaginationButton, SearchBar, TransactionsTable } from "./_components";
import type { NextPage } from "next";
import { hardhat } from "viem/chains";
import { useFetchBlocks } from "~~/hooks/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { notification } from "~~/utils/scaffold-eth";
const BlockExplorer: NextPage = () => {
const { blocks, transactionReceipts, currentPage, totalBlocks, setCurrentPage, error } = useFetchBlocks();
const { targetNetwork } = useTargetNetwork();
const [isLocalNetwork, setIsLocalNetwork] = useState(true);
const [hasError, setHasError] = useState(false);
useEffect(() => {
if (targetNetwork.id !== hardhat.id) {
setIsLocalNetwork(false);
}
}, [targetNetwork.id]);
useEffect(() => {
if (targetNetwork.id === hardhat.id && error) {
setHasError(true);
}
}, [targetNetwork.id, error]);
useEffect(() => {
if (!isLocalNetwork) {
notification.error(
<>
<p className="font-bold mt-0 mb-1">
<code className="italic bg-base-300 text-base font-bold"> targetNetwork </code> is not localhost
</p>
<p className="m-0">
- You are on <code className="italic bg-base-300 text-base font-bold">{targetNetwork.name}</code> .This
block explorer is only for <code className="italic bg-base-300 text-base font-bold">localhost</code>.
</p>
<p className="mt-1 break-normal">
- You can use{" "}
<a className="text-accent" href={targetNetwork.blockExplorers?.default.url}>
{targetNetwork.blockExplorers?.default.name}
</a>{" "}
instead
</p>
</>,
);
}
}, [
isLocalNetwork,
targetNetwork.blockExplorers?.default.name,
targetNetwork.blockExplorers?.default.url,
targetNetwork.name,
]);
useEffect(() => {
if (hasError) {
notification.error(
<>
<p className="font-bold mt-0 mb-1">Cannot connect to local provider</p>
<p className="m-0">
- Did you forget to run <code className="italic bg-base-300 text-base font-bold">yarn chain</code> ?
</p>
<p className="mt-1 break-normal">
- Or you can change <code className="italic bg-base-300 text-base font-bold">targetNetwork</code> in{" "}
<code className="italic bg-base-300 text-base font-bold">scaffold.config.ts</code>
</p>
</>,
);
}
}, [hasError]);
return (
<div className="container mx-auto my-10">
<SearchBar />
<TransactionsTable blocks={blocks} transactionReceipts={transactionReceipts} />
<PaginationButton currentPage={currentPage} totalItems={Number(totalBlocks)} setCurrentPage={setCurrentPage} />
</div>
);
};
export default BlockExplorer;

View File

@@ -0,0 +1,23 @@
import TransactionComp from "../_components/TransactionComp";
import type { NextPage } from "next";
import { Hash } from "viem";
import { isZeroAddress } from "~~/utils/scaffold-eth/common";
type PageProps = {
params: Promise<{ txHash?: Hash }>;
};
export function generateStaticParams() {
// An workaround to enable static exports in Next.js, generating single dummy page.
return [{ txHash: "0x0000000000000000000000000000000000000000" }];
}
const TransactionPage: NextPage<PageProps> = async (props: PageProps) => {
const params = await props.params;
const txHash = params?.txHash as Hash;
if (isZeroAddress(txHash)) return null;
return <TransactionComp txHash={txHash} />;
};
export default TransactionPage;

View File

@@ -0,0 +1,152 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Hash, Transaction, TransactionReceipt, formatEther, formatUnits } from "viem";
import { hardhat } from "viem/chains";
import { usePublicClient } from "wagmi";
import { Address } from "~~/components/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { decodeTransactionData, getFunctionDetails } from "~~/utils/scaffold-eth";
import { replacer } from "~~/utils/scaffold-eth/common";
const TransactionComp = ({ txHash }: { txHash: Hash }) => {
const client = usePublicClient({ chainId: hardhat.id });
const router = useRouter();
const [transaction, setTransaction] = useState<Transaction>();
const [receipt, setReceipt] = useState<TransactionReceipt>();
const [functionCalled, setFunctionCalled] = useState<string>();
const { targetNetwork } = useTargetNetwork();
useEffect(() => {
if (txHash && client) {
const fetchTransaction = async () => {
const tx = await client.getTransaction({ hash: txHash });
const receipt = await client.getTransactionReceipt({ hash: txHash });
const transactionWithDecodedData = decodeTransactionData(tx);
setTransaction(transactionWithDecodedData);
setReceipt(receipt);
const functionCalled = transactionWithDecodedData.input.substring(0, 10);
setFunctionCalled(functionCalled);
};
fetchTransaction();
}
}, [client, txHash]);
return (
<div className="container mx-auto mt-10 mb-20 px-10 md:px-0">
<button className="btn btn-sm btn-primary" onClick={() => router.back()}>
Back
</button>
{transaction ? (
<div className="overflow-x-auto">
<h2 className="text-3xl font-bold mb-4 text-center text-primary-content">Transaction Details</h2>{" "}
<table className="table rounded-lg bg-base-100 w-full shadow-lg md:table-lg table-md">
<tbody>
<tr>
<td>
<strong>Transaction Hash:</strong>
</td>
<td>{transaction.hash}</td>
</tr>
<tr>
<td>
<strong>Block Number:</strong>
</td>
<td>{Number(transaction.blockNumber)}</td>
</tr>
<tr>
<td>
<strong>From:</strong>
</td>
<td>
<Address address={transaction.from} format="long" onlyEnsOrAddress />
</td>
</tr>
<tr>
<td>
<strong>To:</strong>
</td>
<td>
{!receipt?.contractAddress ? (
transaction.to && <Address address={transaction.to} format="long" onlyEnsOrAddress />
) : (
<span>
Contract Creation:
<Address address={receipt.contractAddress} format="long" onlyEnsOrAddress />
</span>
)}
</td>
</tr>
<tr>
<td>
<strong>Value:</strong>
</td>
<td>
{formatEther(transaction.value)} {targetNetwork.nativeCurrency.symbol}
</td>
</tr>
<tr>
<td>
<strong>Function called:</strong>
</td>
<td>
<div className="w-full md:max-w-[600px] lg:max-w-[800px] overflow-x-auto whitespace-nowrap">
{functionCalled === "0x" ? (
"This transaction did not call any function."
) : (
<>
<span className="mr-2">{getFunctionDetails(transaction)}</span>
<span className="badge badge-primary font-bold">{functionCalled}</span>
</>
)}
</div>
</td>
</tr>
<tr>
<td>
<strong>Gas Price:</strong>
</td>
<td>{formatUnits(transaction.gasPrice || 0n, 9)} Gwei</td>
</tr>
<tr>
<td>
<strong>Data:</strong>
</td>
<td className="form-control">
<textarea
readOnly
value={transaction.input}
className="p-0 w-full textarea-primary bg-inherit h-[150px]"
/>
</td>
</tr>
<tr>
<td>
<strong>Logs:</strong>
</td>
<td>
<ul>
{receipt?.logs?.map((log, i) => (
<li key={i}>
<strong>Log {i} topics:</strong> {JSON.stringify(log.topics, replacer, 2)}
</li>
))}
</ul>
</td>
</tr>
</tbody>
</table>
</div>
) : (
<p className="text-2xl text-base-content">Loading...</p>
)}
</div>
);
};
export default TransactionComp;

View File

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useMemo } from "react";
import { useSessionStorage } from "usehooks-ts";
import { BarsArrowUpIcon } from "@heroicons/react/20/solid";
import { ContractUI } from "~~/app/debug/_components/contract";
import { ContractName, GenericContract } from "~~/utils/scaffold-eth/contract";
import { useAllContracts } from "~~/utils/scaffold-eth/contractsData";
const selectedContractStorageKey = "scaffoldEth2.selectedContract";
export function DebugContracts() {
const contractsData = useAllContracts();
const contractNames = useMemo(
() =>
Object.keys(contractsData).sort((a, b) => {
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
}) as ContractName[],
[contractsData],
);
const [selectedContract, setSelectedContract] = useSessionStorage<ContractName>(
selectedContractStorageKey,
contractNames[0],
{ initializeWithValue: false },
);
useEffect(() => {
if (!contractNames.includes(selectedContract)) {
setSelectedContract(contractNames[0]);
}
}, [contractNames, selectedContract, setSelectedContract]);
return (
<div className="flex flex-col gap-y-6 lg:gap-y-8 py-8 lg:py-12 justify-center items-center">
{contractNames.length === 0 ? (
<p className="text-3xl mt-14">No contracts found!</p>
) : (
<>
{contractNames.length > 1 && (
<div className="flex flex-row gap-2 w-full max-w-7xl pb-1 px-6 lg:px-10 flex-wrap">
{contractNames.map(contractName => (
<button
className={`btn btn-secondary btn-sm font-light hover:border-transparent ${
contractName === selectedContract
? "bg-base-300 hover:bg-base-300 no-animation"
: "bg-base-100 hover:bg-secondary"
}`}
key={contractName}
onClick={() => setSelectedContract(contractName)}
>
{contractName}
{(contractsData[contractName] as GenericContract)?.external && (
<span className="tooltip tooltip-top tooltip-accent" data-tip="External contract">
<BarsArrowUpIcon className="h-4 w-4 cursor-pointer" />
</span>
)}
</button>
))}
</div>
)}
{contractNames.map(contractName => (
<ContractUI
key={contractName}
contractName={contractName}
className={contractName === selectedContract ? "" : "hidden"}
/>
))}
</>
)}
</div>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
"use client";
// @refresh reset
import { useReducer } from "react";
import { ContractReadMethods } from "./ContractReadMethods";
import { ContractVariables } from "./ContractVariables";
import { ContractWriteMethods } from "./ContractWriteMethods";
import { Address, Balance } from "~~/components/scaffold-eth";
import { useDeployedContractInfo, useNetworkColor } from "~~/hooks/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { ContractName } from "~~/utils/scaffold-eth/contract";
type ContractUIProps = {
contractName: ContractName;
className?: string;
};
/**
* UI component to interface with deployed contracts.
**/
export const ContractUI = ({ contractName, className = "" }: ContractUIProps) => {
const [refreshDisplayVariables, triggerRefreshDisplayVariables] = useReducer(value => !value, false);
const { targetNetwork } = useTargetNetwork();
const { data: deployedContractData, isLoading: deployedContractLoading } = useDeployedContractInfo({ contractName });
const networkColor = useNetworkColor();
if (deployedContractLoading) {
return (
<div className="mt-14">
<span className="loading loading-spinner loading-lg"></span>
</div>
);
}
if (!deployedContractData) {
return (
<p className="text-3xl mt-14">
{`No contract found by the name of "${contractName}" on chain "${targetNetwork.name}"!`}
</p>
);
}
return (
<div className={`grid grid-cols-1 lg:grid-cols-6 px-6 lg:px-10 lg:gap-12 w-full max-w-7xl my-0 ${className}`}>
<div className="col-span-5 grid grid-cols-1 lg:grid-cols-3 gap-8 lg:gap-10">
<div className="col-span-1 flex flex-col">
<div className="bg-base-100 border-base-300 border shadow-md shadow-secondary rounded-3xl px-6 lg:px-8 mb-6 space-y-1 py-4">
<div className="flex">
<div className="flex flex-col gap-1">
<span className="font-bold">{contractName}</span>
<Address address={deployedContractData.address} onlyEnsOrAddress />
<div className="flex gap-1 items-center">
<span className="font-bold text-sm">Balance:</span>
<Balance address={deployedContractData.address} className="px-0 h-1.5 min-h-[0.375rem]" />
</div>
</div>
</div>
{targetNetwork && (
<p className="my-0 text-sm">
<span className="font-bold">Network</span>:{" "}
<span style={{ color: networkColor }}>{targetNetwork.name}</span>
</p>
)}
</div>
<div className="bg-base-300 rounded-3xl px-6 lg:px-8 py-4 shadow-lg shadow-base-300">
<ContractVariables
refreshDisplayVariables={refreshDisplayVariables}
deployedContractData={deployedContractData}
/>
</div>
</div>
<div className="col-span-1 lg:col-span-2 flex flex-col gap-6">
<div className="z-10">
<div className="bg-base-100 rounded-3xl shadow-md shadow-secondary border border-base-300 flex flex-col mt-10 relative">
<div className="h-[5rem] w-[5.5rem] bg-base-300 absolute self-start rounded-[22px] -top-[38px] -left-[1px] -z-10 py-[0.65rem] shadow-lg shadow-base-300">
<div className="flex items-center justify-center space-x-2">
<p className="my-0 text-sm">Read</p>
</div>
</div>
<div className="p-5 divide-y divide-base-300">
<ContractReadMethods deployedContractData={deployedContractData} />
</div>
</div>
</div>
<div className="z-10">
<div className="bg-base-100 rounded-3xl shadow-md shadow-secondary border border-base-300 flex flex-col mt-10 relative">
<div className="h-[5rem] w-[5.5rem] bg-base-300 absolute self-start rounded-[22px] -top-[38px] -left-[1px] -z-10 py-[0.65rem] shadow-lg shadow-base-300">
<div className="flex items-center justify-center space-x-2">
<p className="my-0 text-sm">Write</p>
</div>
</div>
<div className="p-5 divide-y divide-base-300">
<ContractWriteMethods
deployedContractData={deployedContractData}
onChange={triggerRefreshDisplayVariables}
/>
</div>
</div>
</div>
</div>
</div>
</div>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,102 @@
"use client";
import { useEffect, 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>
);
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>
);
};

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
import { DebugContracts } from "./_components/DebugContracts";
import type { NextPage } from "next";
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
export const metadata = getMetadata({
title: "Debug Contracts",
description: "Debug your deployed 🏗 Scaffold-ETH 2 contracts in an easy way",
});
const Debug: NextPage = () => {
return (
<>
<DebugContracts />
<div className="text-center mt-8 bg-secondary p-10">
<h1 className="text-4xl my-0">Debug Contracts</h1>
<p className="text-neutral">
You can debug & interact with your deployed contracts here.
<br /> Check{" "}
<code className="italic bg-base-300 text-base font-bold [word-spacing:-0.5rem] px-1">
packages / nextjs / app / debug / page.tsx
</code>{" "}
</p>
</div>
</>
);
};
export default Debug;

View File

@@ -0,0 +1,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>
);
};

View File

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

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

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

View File

@@ -0,0 +1,30 @@
import { Space_Grotesk } from "next/font/google";
import "@rainbow-me/rainbowkit/styles.css";
import { ScaffoldEthAppWithProviders } from "~~/components/ScaffoldEthAppWithProviders";
import { ThemeProvider } from "~~/components/ThemeProvider";
import "~~/styles/globals.css";
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
const spaceGrotesk = Space_Grotesk({
subsets: ["latin"],
variable: "--font-space-grotesk",
});
export const metadata = getMetadata({
title: "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;

View File

@@ -0,0 +1,16 @@
import Link from "next/link";
export default function NotFound() {
return (
<div className="flex items-center h-full flex-1 justify-center bg-base-200">
<div className="text-center">
<h1 className="text-6xl font-bold m-0 mb-1">404</h1>
<h2 className="text-2xl font-semibold m-0">Page Not Found</h2>
<p className="text-base-content/70 m-0 mb-4">The page you&apos;re looking for doesn&apos;t exist.</p>
<Link href="/" className="btn btn-primary">
Go Home
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,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;

View File

@@ -0,0 +1,80 @@
import React from "react";
import Link from "next/link";
import { hardhat } from "viem/chains";
import { CurrencyDollarIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
import { HeartIcon } from "@heroicons/react/24/outline";
import { SwitchTheme } from "~~/components/SwitchTheme";
import { BuidlGuidlLogo } from "~~/components/assets/BuidlGuidlLogo";
import { Faucet } from "~~/components/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { useGlobalState } from "~~/services/store/store";
/**
* Site footer
*/
export const Footer = () => {
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
const { targetNetwork } = useTargetNetwork();
const isLocalNetwork = targetNetwork.id === hardhat.id;
return (
<div className="min-h-0 py-5 px-1 mb-11 lg:mb-0">
<div>
<div className="fixed flex justify-between items-center w-full z-10 p-4 bottom-0 left-0 pointer-events-none">
<div className="flex flex-col md:flex-row gap-2 pointer-events-auto">
{nativeCurrencyPrice > 0 && (
<div>
<div className="btn btn-primary btn-sm font-normal gap-1 cursor-auto">
<CurrencyDollarIcon className="h-4 w-4" />
<span>{nativeCurrencyPrice.toFixed(2)}</span>
</div>
</div>
)}
{isLocalNetwork && (
<>
<Faucet />
<Link href="/blockexplorer" passHref className="btn btn-primary btn-sm font-normal gap-1">
<MagnifyingGlassIcon className="h-4 w-4" />
<span>Block Explorer</span>
</Link>
</>
)}
</div>
<SwitchTheme className={`pointer-events-auto ${isLocalNetwork ? "self-end md:self-auto" : ""}`} />
</div>
</div>
<div className="w-full">
<ul className="menu menu-horizontal w-full">
<div className="flex justify-center items-center gap-2 text-sm w-full">
<div className="text-center">
<a href="https://github.com/scaffold-eth/se-2" target="_blank" rel="noreferrer" className="link">
Fork me
</a>
</div>
<span>·</span>
<div className="flex justify-center items-center gap-2">
<p className="m-0 text-center">
Built with <HeartIcon className="inline-block h-4 w-4" /> at
</p>
<a
className="flex justify-center items-center gap-1"
href="https://buidlguidl.com/"
target="_blank"
rel="noreferrer"
>
<BuidlGuidlLogo className="w-3 h-5 pb-1" />
<span className="link">BuidlGuidl</span>
</a>
</div>
<span>·</span>
<div className="text-center">
<a href="https://t.me/joinchat/KByvmRe5wkR-8F_zz6AjpA" target="_blank" rel="noreferrer" className="link">
Support
</a>
</div>
</div>
</ul>
</div>
</div>
);
};

View File

@@ -0,0 +1,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>
);
};

View File

@@ -0,0 +1,61 @@
"use client";
import { useEffect, useState } from "react";
import { RainbowKitProvider, darkTheme, lightTheme } from "@rainbow-me/rainbowkit";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AppProgressBar as ProgressBar } from "next-nprogress-bar";
import { useTheme } from "next-themes";
import { Toaster } from "react-hot-toast";
import { WagmiProvider } from "wagmi";
import { Footer } from "~~/components/Footer";
import { Header } from "~~/components/Header";
import { BlockieAvatar } from "~~/components/scaffold-eth";
import { useInitializeNativeCurrencyPrice } from "~~/hooks/scaffold-eth";
import { wagmiConfig } from "~~/services/web3/wagmiConfig";
const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
useInitializeNativeCurrencyPrice();
return (
<>
<div className={`flex flex-col min-h-screen `}>
<Header />
<main className="relative flex flex-col flex-1">{children}</main>
<Footer />
</div>
<Toaster />
</>
);
};
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
export const ScaffoldEthAppWithProviders = ({ children }: { children: React.ReactNode }) => {
const { resolvedTheme } = useTheme();
const isDarkMode = resolvedTheme === "dark";
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<WagmiProvider config={wagmiConfig}>
<QueryClientProvider client={queryClient}>
<RainbowKitProvider
avatar={BlockieAvatar}
theme={mounted ? (isDarkMode ? darkTheme() : lightTheme()) : lightTheme()}
>
<ProgressBar height="3px" color="#2299dd" />
<ScaffoldEthApp>{children}</ScaffoldEthApp>
</RainbowKitProvider>
</QueryClientProvider>
</WagmiProvider>
);
};

View File

@@ -0,0 +1,42 @@
"use client";
import { useEffect, useState } from "react";
import { useTheme } from "next-themes";
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
export const SwitchTheme = ({ className }: { className?: string }) => {
const { setTheme, resolvedTheme } = useTheme();
const [mounted, setMounted] = useState(false);
const isDarkMode = resolvedTheme === "dark";
const handleToggle = () => {
if (isDarkMode) {
setTheme("light");
return;
}
setTheme("dark");
};
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return null;
return (
<div className={`flex space-x-2 h-8 items-center justify-center text-sm ${className}`}>
<input
id="theme-toggle"
type="checkbox"
className="toggle bg-secondary toggle-primary hover:bg-accent transition-all"
onChange={handleToggle}
checked={isDarkMode}
/>
<label htmlFor="theme-toggle" className={`swap swap-rotate ${!isDarkMode ? "swap-active" : ""}`}>
<SunIcon className="swap-on h-5 w-5" />
<MoonIcon className="swap-off h-5 w-5" />
</label>
</div>
);
};

View File

@@ -0,0 +1,9 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes/dist/types";
export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
};

View File

@@ -0,0 +1,18 @@
export const BuidlGuidlLogo = ({ className }: { className: string }) => {
return (
<svg
className={className}
width="53"
height="72"
viewBox="0 0 53 72"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
d="M25.9 17.434v15.638h3.927v9.04h9.718v-9.04h6.745v18.08l-10.607 19.88-12.11-.182-12.11.183L.856 51.152v-18.08h6.713v9.04h9.75v-9.04h4.329V2.46a2.126 2.126 0 0 1 4.047-.914c1.074.412 2.157 1.5 3.276 2.626 1.33 1.337 2.711 2.726 4.193 3.095 1.496.373 2.605-.026 3.855-.475 1.31-.47 2.776-.997 5.005-.747 1.67.197 2.557 1.289 3.548 2.509 1.317 1.623 2.82 3.473 6.599 3.752l-.024.017c-2.42 1.709-5.726 4.043-10.86 3.587-1.605-.139-2.736-.656-3.82-1.153-1.546-.707-2.997-1.37-5.59-.832-2.809.563-4.227 1.892-5.306 2.903-.236.221-.456.427-.67.606Z"
clipRule="evenodd"
/>
</svg>
);
};

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
"use client";
import { AvatarComponent } from "@rainbow-me/rainbowkit";
import { blo } from "blo";
// Custom Avatar for RainbowKit
export const BlockieAvatar: AvatarComponent = ({ address, ensImage, size }) => (
// Don't want to use nextJS Image here (and adding remote patterns for the URL)
// eslint-disable-next-line @next/next/no-img-element
<img
className="rounded-full"
src={ensImage || blo(address as `0x${string}`)}
width={size}
height={size}
alt={`${address} avatar`}
/>
);

View File

@@ -0,0 +1,129 @@
"use client";
import { useEffect, useState } from "react";
import { Address as AddressType, createWalletClient, http, parseEther } from "viem";
import { hardhat } from "viem/chains";
import { useAccount } from "wagmi";
import { BanknotesIcon } from "@heroicons/react/24/outline";
import { Address, AddressInput, Balance, EtherInput } from "~~/components/scaffold-eth";
import { useTransactor } from "~~/hooks/scaffold-eth";
import { notification } from "~~/utils/scaffold-eth";
// Account index to use from generated hardhat accounts.
const FAUCET_ACCOUNT_INDEX = 0;
const localWalletClient = createWalletClient({
chain: hardhat,
transport: http(),
});
/**
* Faucet modal which lets you send ETH to any address.
*/
export const Faucet = () => {
const [loading, setLoading] = useState(false);
const [inputAddress, setInputAddress] = useState<AddressType>();
const [faucetAddress, setFaucetAddress] = useState<AddressType>();
const [sendValue, setSendValue] = useState("");
const { chain: ConnectedChain } = useAccount();
const faucetTxn = useTransactor(localWalletClient);
useEffect(() => {
const getFaucetAddress = async () => {
try {
const accounts = await localWalletClient.getAddresses();
setFaucetAddress(accounts[FAUCET_ACCOUNT_INDEX]);
} catch (error) {
notification.error(
<>
<p className="font-bold mt-0 mb-1">Cannot connect to local provider</p>
<p className="m-0">
- Did you forget to run <code className="italic bg-base-300 text-base font-bold">yarn chain</code> ?
</p>
<p className="mt-1 break-normal">
- Or you can change <code className="italic bg-base-300 text-base font-bold">targetNetwork</code> in{" "}
<code className="italic bg-base-300 text-base font-bold">scaffold.config.ts</code>
</p>
</>,
);
console.error("⚡️ ~ file: Faucet.tsx:getFaucetAddress ~ error", error);
}
};
getFaucetAddress();
}, []);
const sendETH = async () => {
if (!faucetAddress || !inputAddress) {
return;
}
try {
setLoading(true);
await faucetTxn({
to: inputAddress,
value: parseEther(sendValue as `${number}`),
account: faucetAddress,
});
setLoading(false);
setInputAddress(undefined);
setSendValue("");
} catch (error) {
console.error("⚡️ ~ file: Faucet.tsx:sendETH ~ error", error);
setLoading(false);
}
};
// Render only on local chain
if (ConnectedChain?.id !== hardhat.id) {
return null;
}
return (
<div>
<label htmlFor="faucet-modal" className="btn btn-primary btn-sm font-normal gap-1">
<BanknotesIcon className="h-4 w-4" />
<span>Faucet</span>
</label>
<input type="checkbox" id="faucet-modal" className="modal-toggle" />
<label htmlFor="faucet-modal" className="modal cursor-pointer">
<label className="modal-box relative">
{/* dummy input to capture event onclick on modal box */}
<input className="h-0 w-0 absolute top-0 left-0" />
<h3 className="text-xl font-bold mb-3">Local Faucet</h3>
<label htmlFor="faucet-modal" className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
</label>
<div className="space-y-3">
<div className="flex space-x-4">
<div>
<span className="text-sm font-bold">From:</span>
<Address address={faucetAddress} onlyEnsOrAddress />
</div>
<div>
<span className="text-sm font-bold pl-3">Available:</span>
<Balance address={faucetAddress} />
</div>
</div>
<div className="flex flex-col space-y-3">
<AddressInput
placeholder="Destination Address"
value={inputAddress ?? ""}
onChange={value => setInputAddress(value as AddressType)}
/>
<EtherInput placeholder="Amount to send" value={sendValue} onChange={value => setSendValue(value)} />
<button className="h-10 btn btn-primary btn-sm px-2 rounded-full" onClick={sendETH} disabled={loading}>
{!loading ? (
<BanknotesIcon className="h-6 w-6" />
) : (
<span className="loading loading-spinner loading-sm"></span>
)}
<span>Send</span>
</button>
</div>
</div>
</label>
</label>
</div>
);
};

View File

@@ -0,0 +1,73 @@
"use client";
import { useState } from "react";
import { createWalletClient, http, parseEther } from "viem";
import { hardhat } from "viem/chains";
import { useAccount } from "wagmi";
import { BanknotesIcon } from "@heroicons/react/24/outline";
import { useTransactor } from "~~/hooks/scaffold-eth";
import { useWatchBalance } from "~~/hooks/scaffold-eth/useWatchBalance";
// Number of ETH faucet sends to an address
const NUM_OF_ETH = "1";
const FAUCET_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
const localWalletClient = createWalletClient({
chain: hardhat,
transport: http(),
});
/**
* FaucetButton button which lets you grab eth.
*/
export const FaucetButton = () => {
const { address, chain: ConnectedChain } = useAccount();
const { data: balance } = useWatchBalance({ address });
const [loading, setLoading] = useState(false);
const faucetTxn = useTransactor(localWalletClient);
const sendETH = async () => {
if (!address) return;
try {
setLoading(true);
await faucetTxn({
account: FAUCET_ADDRESS,
to: address,
value: parseEther(NUM_OF_ETH),
});
setLoading(false);
} catch (error) {
console.error("⚡️ ~ file: FaucetButton.tsx:sendETH ~ error", error);
setLoading(false);
}
};
// Render only on local chain
if (ConnectedChain?.id !== hardhat.id) {
return null;
}
const isBalanceZero = balance && balance.value === 0n;
return (
<div
className={
!isBalanceZero
? "ml-1"
: "ml-1 tooltip tooltip-bottom tooltip-primary tooltip-open font-bold before:left-auto before:transform-none before:content-[attr(data-tip)] before:-translate-x-2/5"
}
data-tip="Grab funds from faucet"
>
<button className="btn btn-secondary btn-sm px-2 rounded-full" onClick={sendETH} disabled={loading}>
{!loading ? (
<BanknotesIcon className="h-4 w-4" />
) : (
<span className="loading loading-spinner loading-xs"></span>
)}
</button>
</div>
);
};

View File

@@ -0,0 +1,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" />
}
/>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,32 @@
import { NetworkOptions } from "./NetworkOptions";
import { useDisconnect } from "wagmi";
import { ArrowLeftOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
export const WrongNetworkDropdown = () => {
const { disconnect } = useDisconnect();
return (
<div className="dropdown dropdown-end mr-2">
<label tabIndex={0} className="btn btn-error btn-sm dropdown-toggle gap-1">
<span>Wrong network</span>
<ChevronDownIcon className="h-6 w-4 ml-2 sm:ml-0" />
</label>
<ul
tabIndex={0}
className="dropdown-content menu p-2 mt-1 shadow-center shadow-accent bg-base-200 rounded-box gap-1"
>
<NetworkOptions />
<li>
<button
className="menu-item text-error btn-sm rounded-xl! flex gap-3 py-3"
type="button"
onClick={() => disconnect()}
>
<ArrowLeftOnRectangleIcon className="h-6 w-4 ml-2 sm:ml-0" />
<span>Disconnect</span>
</button>
</li>
</ul>
</div>
);
};

View File

@@ -0,0 +1,69 @@
"use client";
// @refresh reset
import { Balance } from "../Balance";
import { AddressInfoDropdown } from "./AddressInfoDropdown";
import { AddressQRCodeModal } from "./AddressQRCodeModal";
import { RevealBurnerPKModal } from "./RevealBurnerPKModal";
import { WrongNetworkDropdown } from "./WrongNetworkDropdown";
import { ConnectButton } from "@rainbow-me/rainbowkit";
import { Address } from "viem";
import { useNetworkColor } from "~~/hooks/scaffold-eth";
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
import { getBlockExplorerAddressLink } from "~~/utils/scaffold-eth";
/**
* Custom Wagmi Connect Button (watch balance + custom design)
*/
export const RainbowKitCustomConnectButton = () => {
const networkColor = useNetworkColor();
const { targetNetwork } = useTargetNetwork();
return (
<ConnectButton.Custom>
{({ account, chain, openConnectModal, mounted }) => {
const connected = mounted && account && chain;
const blockExplorerAddressLink = account
? getBlockExplorerAddressLink(targetNetwork, account.address)
: undefined;
return (
<>
{(() => {
if (!connected) {
return (
<button className="btn btn-primary btn-sm" onClick={openConnectModal} type="button">
Connect Wallet
</button>
);
}
if (chain.unsupported || chain.id !== targetNetwork.id) {
return <WrongNetworkDropdown />;
}
return (
<>
<div className="flex flex-col items-center mr-1">
<Balance address={account.address as Address} className="min-h-0 h-auto" />
<span className="text-xs" style={{ color: networkColor }}>
{chain.name}
</span>
</div>
<AddressInfoDropdown
address={account.address as Address}
displayName={account.displayName}
ensAvatar={account.ensAvatar}
blockExplorerAddressLink={blockExplorerAddressLink}
/>
<AddressQRCodeModal address={account.address as Address} modalId="qrcode-modal" />
<RevealBurnerPKModal />
</>
);
})()}
</>
);
}}
</ConnectButton.Custom>
);
};

View File

@@ -0,0 +1,7 @@
export * from "./Address/Address";
export * from "./Balance";
export * from "./BlockieAvatar";
export * from "./Faucet";
export * from "./FaucetButton";
export * from "./Input";
export * from "./RainbowKitCustomConnectButton";

View File

@@ -0,0 +1,9 @@
/**
* This file is autogenerated by Scaffold-ETH.
* You should not edit it manually or your changes might be overwritten.
*/
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
const deployedContracts = {} as const;
export default deployedContracts satisfies GenericContractsDeclaration;

View File

@@ -0,0 +1,16 @@
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
/**
* @example
* const externalContracts = {
* 1: {
* DAI: {
* address: "0x...",
* abi: [...],
* },
* },
* } as const;
*/
const externalContracts = {} as const;
export default externalContracts satisfies GenericContractsDeclaration;

View File

@@ -0,0 +1,32 @@
import { FlatCompat } from "@eslint/eslintrc";
import prettierPlugin from "eslint-plugin-prettier";
import { defineConfig } from "eslint/config";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
export default defineConfig([
{
plugins: {
prettier: prettierPlugin,
},
extends: compat.extends("next/core-web-vitals", "next/typescript", "prettier"),
rules: {
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
"prettier/prettier": [
"warn",
{
endOfLine: "auto",
},
],
},
},
]);

View File

@@ -0,0 +1,17 @@
export * from "./useAnimationConfig";
export * from "./useContractLogs";
export * from "./useCopyToClipboard";
export * from "./useDeployedContractInfo";
export * from "./useFetchBlocks";
export * from "./useInitializeNativeCurrencyPrice";
export * from "./useNetworkColor";
export * from "./useOutsideClick";
export * from "./useScaffoldContract";
export * from "./useScaffoldEventHistory";
export * from "./useScaffoldReadContract";
export * from "./useScaffoldWatchContractEvent";
export * from "./useScaffoldWriteContract";
export * from "./useTargetNetwork";
export * from "./useTransactor";
export * from "./useWatchBalance";
export * from "./useSelectedNetwork";

View File

@@ -0,0 +1,20 @@
import { useEffect, useState } from "react";
const ANIMATION_TIME = 2000;
export function useAnimationConfig(data: any) {
const [showAnimation, setShowAnimation] = useState(false);
const [prevData, setPrevData] = useState();
useEffect(() => {
if (prevData !== undefined && prevData !== data) {
setShowAnimation(true);
setTimeout(() => setShowAnimation(false), ANIMATION_TIME);
}
setPrevData(data);
}, [data, prevData]);
return {
showAnimation,
};
}

View File

@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { useTargetNetwork } from "./useTargetNetwork";
import { Address, Log } from "viem";
import { usePublicClient } from "wagmi";
export const useContractLogs = (address: Address) => {
const [logs, setLogs] = useState<Log[]>([]);
const { targetNetwork } = useTargetNetwork();
const client = usePublicClient({ chainId: targetNetwork.id });
useEffect(() => {
const fetchLogs = async () => {
if (!client) return console.error("Client not found");
try {
const existingLogs = await client.getLogs({
address: address,
fromBlock: 0n,
toBlock: "latest",
});
setLogs(existingLogs);
} catch (error) {
console.error("Failed to fetch logs:", error);
}
};
fetchLogs();
return client?.watchBlockNumber({
onBlockNumber: async (_blockNumber, prevBlockNumber) => {
const newLogs = await client.getLogs({
address: address,
fromBlock: prevBlockNumber,
toBlock: "latest",
});
setLogs(prevLogs => [...prevLogs, ...newLogs]);
},
});
}, [address, client]);
return logs;
};

View File

@@ -0,0 +1,19 @@
import { useState } from "react";
export const useCopyToClipboard = () => {
const [isCopiedToClipboard, setIsCopiedToClipboard] = useState(false);
const copyToClipboard = async (text: string) => {
try {
await navigator.clipboard.writeText(text);
setIsCopiedToClipboard(true);
setTimeout(() => {
setIsCopiedToClipboard(false);
}, 800);
} catch (err) {
console.error("Failed to copy text:", err);
}
};
return { copyToClipboard, isCopiedToClipboard };
};

View File

@@ -0,0 +1,86 @@
import { useEffect, useState } from "react";
import { useIsMounted } from "usehooks-ts";
import { usePublicClient } from "wagmi";
import { useSelectedNetwork } from "~~/hooks/scaffold-eth";
import {
Contract,
ContractCodeStatus,
ContractName,
UseDeployedContractConfig,
contracts,
} from "~~/utils/scaffold-eth/contract";
type DeployedContractData<TContractName extends ContractName> = {
data: Contract<TContractName> | undefined;
isLoading: boolean;
};
/**
* Gets the matching contract info for the provided contract name from the contracts present in deployedContracts.ts
* and externalContracts.ts corresponding to targetNetworks configured in scaffold.config.ts
*/
export function useDeployedContractInfo<TContractName extends ContractName>(
config: UseDeployedContractConfig<TContractName>,
): DeployedContractData<TContractName>;
/**
* @deprecated Use object parameter version instead: useDeployedContractInfo({ contractName: "YourContract" })
*/
export function useDeployedContractInfo<TContractName extends ContractName>(
contractName: TContractName,
): DeployedContractData<TContractName>;
export function useDeployedContractInfo<TContractName extends ContractName>(
configOrName: UseDeployedContractConfig<TContractName> | TContractName,
): DeployedContractData<TContractName> {
const isMounted = useIsMounted();
const finalConfig: UseDeployedContractConfig<TContractName> =
typeof configOrName === "string" ? { contractName: configOrName } : (configOrName as any);
useEffect(() => {
if (typeof configOrName === "string") {
console.warn(
"Using `useDeployedContractInfo` with a string parameter is deprecated. Please use the object parameter version instead.",
);
}
}, [configOrName]);
const { contractName, chainId } = finalConfig;
const selectedNetwork = useSelectedNetwork(chainId);
const deployedContract = contracts?.[selectedNetwork.id]?.[contractName as ContractName] as Contract<TContractName>;
const [status, setStatus] = useState<ContractCodeStatus>(ContractCodeStatus.LOADING);
const publicClient = usePublicClient({ chainId: selectedNetwork.id });
useEffect(() => {
const checkContractDeployment = async () => {
try {
if (!isMounted() || !publicClient) return;
if (!deployedContract) {
setStatus(ContractCodeStatus.NOT_FOUND);
return;
}
const code = await publicClient.getBytecode({
address: deployedContract.address,
});
// If contract code is `0x` => no contract deployed on that address
if (code === "0x") {
setStatus(ContractCodeStatus.NOT_FOUND);
return;
}
setStatus(ContractCodeStatus.DEPLOYED);
} catch (e) {
console.error(e);
setStatus(ContractCodeStatus.NOT_FOUND);
}
};
checkContractDeployment();
}, [isMounted, contractName, deployedContract, publicClient]);
return {
data: status === ContractCodeStatus.DEPLOYED ? deployedContract : undefined,
isLoading: status === ContractCodeStatus.LOADING,
};
}

View File

@@ -0,0 +1,21 @@
import { useCallback, useEffect, useState } from "react";
import { useGlobalState } from "~~/services/store/store";
export const useDisplayUsdMode = ({ defaultUsdMode = false }: { defaultUsdMode?: boolean }) => {
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
const isPriceFetched = nativeCurrencyPrice > 0;
const predefinedUsdMode = isPriceFetched ? Boolean(defaultUsdMode) : false;
const [displayUsdMode, setDisplayUsdMode] = useState(predefinedUsdMode);
useEffect(() => {
setDisplayUsdMode(predefinedUsdMode);
}, [predefinedUsdMode]);
const toggleDisplayUsdMode = useCallback(() => {
if (isPriceFetched) {
setDisplayUsdMode(!displayUsdMode);
}
}, [displayUsdMode, isPriceFetched]);
return { displayUsdMode, toggleDisplayUsdMode };
};

View File

@@ -0,0 +1,133 @@
import { useCallback, useEffect, useState } from "react";
import {
Block,
Hash,
Transaction,
TransactionReceipt,
createTestClient,
publicActions,
walletActions,
webSocket,
} from "viem";
import { hardhat } from "viem/chains";
import { decodeTransactionData } from "~~/utils/scaffold-eth";
const BLOCKS_PER_PAGE = 20;
export const testClient = createTestClient({
chain: hardhat,
mode: "hardhat",
transport: webSocket("ws://127.0.0.1:8545"),
})
.extend(publicActions)
.extend(walletActions);
export const useFetchBlocks = () => {
const [blocks, setBlocks] = useState<Block[]>([]);
const [transactionReceipts, setTransactionReceipts] = useState<{
[key: string]: TransactionReceipt;
}>({});
const [currentPage, setCurrentPage] = useState(0);
const [totalBlocks, setTotalBlocks] = useState(0n);
const [error, setError] = useState<Error | null>(null);
const fetchBlocks = useCallback(async () => {
setError(null);
try {
const blockNumber = await testClient.getBlockNumber();
setTotalBlocks(blockNumber);
const startingBlock = blockNumber - BigInt(currentPage * BLOCKS_PER_PAGE);
const blockNumbersToFetch = Array.from(
{ length: Number(BLOCKS_PER_PAGE < startingBlock + 1n ? BLOCKS_PER_PAGE : startingBlock + 1n) },
(_, i) => startingBlock - BigInt(i),
);
const blocksWithTransactions = blockNumbersToFetch.map(async blockNumber => {
try {
return testClient.getBlock({ blockNumber, includeTransactions: true });
} catch (err) {
setError(err instanceof Error ? err : new Error("An error occurred."));
throw err;
}
});
const fetchedBlocks = await Promise.all(blocksWithTransactions);
fetchedBlocks.forEach(block => {
block.transactions.forEach(tx => decodeTransactionData(tx as Transaction));
});
const txReceipts = await Promise.all(
fetchedBlocks.flatMap(block =>
block.transactions.map(async tx => {
try {
const receipt = await testClient.getTransactionReceipt({ hash: (tx as Transaction).hash });
return { [(tx as Transaction).hash]: receipt };
} catch (err) {
setError(err instanceof Error ? err : new Error("An error occurred."));
throw err;
}
}),
),
);
setBlocks(fetchedBlocks);
setTransactionReceipts(prevReceipts => ({ ...prevReceipts, ...Object.assign({}, ...txReceipts) }));
} catch (err) {
setError(err instanceof Error ? err : new Error("An error occurred."));
}
}, [currentPage]);
useEffect(() => {
fetchBlocks();
}, [fetchBlocks]);
useEffect(() => {
const handleNewBlock = async (newBlock: any) => {
try {
if (currentPage === 0) {
if (newBlock.transactions.length > 0) {
const transactionsDetails = await Promise.all(
newBlock.transactions.map((txHash: string) => testClient.getTransaction({ hash: txHash as Hash })),
);
newBlock.transactions = transactionsDetails;
}
newBlock.transactions.forEach((tx: Transaction) => decodeTransactionData(tx as Transaction));
const receipts = await Promise.all(
newBlock.transactions.map(async (tx: Transaction) => {
try {
const receipt = await testClient.getTransactionReceipt({ hash: (tx as Transaction).hash });
return { [(tx as Transaction).hash]: receipt };
} catch (err) {
setError(err instanceof Error ? err : new Error("An error occurred fetching receipt."));
throw err;
}
}),
);
setBlocks(prevBlocks => [newBlock, ...prevBlocks.slice(0, BLOCKS_PER_PAGE - 1)]);
setTransactionReceipts(prevReceipts => ({ ...prevReceipts, ...Object.assign({}, ...receipts) }));
}
if (newBlock.number) {
setTotalBlocks(newBlock.number);
}
} catch (err) {
setError(err instanceof Error ? err : new Error("An error occurred."));
}
};
return testClient.watchBlocks({ onBlock: handleNewBlock, includeTransactions: true });
}, [currentPage]);
return {
blocks,
transactionReceipts,
currentPage,
totalBlocks,
setCurrentPage,
error,
};
};

View File

@@ -0,0 +1,32 @@
import { useCallback, useEffect } from "react";
import { useTargetNetwork } from "./useTargetNetwork";
import { useInterval } from "usehooks-ts";
import scaffoldConfig from "~~/scaffold.config";
import { useGlobalState } from "~~/services/store/store";
import { fetchPriceFromUniswap } from "~~/utils/scaffold-eth";
const enablePolling = false;
/**
* Get the price of Native Currency based on Native Token/DAI trading pair from Uniswap SDK
*/
export const useInitializeNativeCurrencyPrice = () => {
const setNativeCurrencyPrice = useGlobalState(state => state.setNativeCurrencyPrice);
const setIsNativeCurrencyFetching = useGlobalState(state => state.setIsNativeCurrencyFetching);
const { targetNetwork } = useTargetNetwork();
const fetchPrice = useCallback(async () => {
setIsNativeCurrencyFetching(true);
const price = await fetchPriceFromUniswap(targetNetwork);
setNativeCurrencyPrice(price);
setIsNativeCurrencyFetching(false);
}, [setIsNativeCurrencyFetching, setNativeCurrencyPrice, targetNetwork]);
// Get the price of ETH from Uniswap on mount
useEffect(() => {
fetchPrice();
}, [fetchPrice]);
// Get the price of ETH from Uniswap at a given interval
useInterval(fetchPrice, enablePolling ? scaffoldConfig.pollingInterval : null);
};

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