Initial commit with 🏗️ Scaffold-ETH 2 @ 1.0.5
This commit is contained in:
13
packages/hardhat/.env.example
Normal file
13
packages/hardhat/.env.example
Normal file
@@ -0,0 +1,13 @@
|
||||
# Template for Hardhat environment variables.
|
||||
|
||||
# To use this template, copy this file, rename it .env, and fill in the values.
|
||||
|
||||
# If not set, we provide default values (check `hardhat.config.ts`) so developers can start prototyping out of the box,
|
||||
# but we recommend getting your own API Keys for Production Apps.
|
||||
|
||||
# To access the values stored in this .env file you can use: process.env.VARIABLENAME
|
||||
ALCHEMY_API_KEY=
|
||||
ETHERSCAN_V2_API_KEY=
|
||||
|
||||
# Don't fill this value manually, run yarn generate to generate a new account or yarn account:import to import an existing PK.
|
||||
DEPLOYER_PRIVATE_KEY_ENCRYPTED=
|
||||
30
packages/hardhat/.gitignore
vendored
Normal file
30
packages/hardhat/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
|
||||
# env files
|
||||
.env
|
||||
|
||||
# coverage
|
||||
coverage
|
||||
coverage.json
|
||||
|
||||
# typechain
|
||||
typechain
|
||||
typechain-types
|
||||
|
||||
# hardhat files
|
||||
cache
|
||||
artifacts
|
||||
|
||||
# zkSync files
|
||||
artifacts-zk
|
||||
cache-zk
|
||||
|
||||
# deployments
|
||||
deployments/localhost
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# other
|
||||
temp
|
||||
18
packages/hardhat/.prettierrc.json
Normal file
18
packages/hardhat/.prettierrc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"plugins": ["prettier-plugin-solidity"],
|
||||
"arrowParens": "avoid",
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "all",
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.sol",
|
||||
"options": {
|
||||
"printWidth": 120,
|
||||
"tabWidth": 4,
|
||||
"singleQuote": false,
|
||||
"bracketSpacing": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
49
packages/hardhat/contracts/YourCollectible.sol
Normal file
49
packages/hardhat/contracts/YourCollectible.sol
Normal file
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
pragma solidity ^0.8.20;
|
||||
|
||||
import "@openzeppelin/contracts/token/ERC721/ERC721.sol";
|
||||
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";
|
||||
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721URIStorage.sol";
|
||||
import "@openzeppelin/contracts/access/Ownable.sol";
|
||||
|
||||
contract YourCollectible is ERC721, ERC721Enumerable, ERC721URIStorage, Ownable {
|
||||
uint256 public tokenIdCounter;
|
||||
|
||||
constructor() ERC721("YourCollectible", "YCB") Ownable(msg.sender) {}
|
||||
|
||||
function _baseURI() internal pure override returns (string memory) {
|
||||
return "https://ipfs.io/ipfs/";
|
||||
}
|
||||
|
||||
function mintItem(address to, string memory uri) public returns (uint256) {
|
||||
tokenIdCounter++;
|
||||
uint256 tokenId = tokenIdCounter;
|
||||
_safeMint(to, tokenId);
|
||||
_setTokenURI(tokenId, uri);
|
||||
return tokenId;
|
||||
}
|
||||
|
||||
// Override functions from OpenZeppelin ERC721, ERC721Enumerable and ERC721URIStorage
|
||||
|
||||
function _update(
|
||||
address to,
|
||||
uint256 tokenId,
|
||||
address auth
|
||||
) internal override(ERC721, ERC721Enumerable) returns (address) {
|
||||
return super._update(to, tokenId, auth);
|
||||
}
|
||||
|
||||
function _increaseBalance(address account, uint128 value) internal override(ERC721, ERC721Enumerable) {
|
||||
super._increaseBalance(account, value);
|
||||
}
|
||||
|
||||
function tokenURI(uint256 tokenId) public view override(ERC721, ERC721URIStorage) returns (string memory) {
|
||||
return super.tokenURI(tokenId);
|
||||
}
|
||||
|
||||
function supportsInterface(
|
||||
bytes4 interfaceId
|
||||
) public view override(ERC721, ERC721Enumerable, ERC721URIStorage) returns (bool) {
|
||||
return super.supportsInterface(interfaceId);
|
||||
}
|
||||
}
|
||||
43
packages/hardhat/deploy/01_deploy_your_collectible.ts
Normal file
43
packages/hardhat/deploy/01_deploy_your_collectible.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { HardhatRuntimeEnvironment } from "hardhat/types";
|
||||
import { DeployFunction } from "hardhat-deploy/types";
|
||||
import { Contract } from "ethers";
|
||||
|
||||
/**
|
||||
* Deploys a contract named "YourCollectible" using the deployer account and
|
||||
* constructor arguments set to the deployer address
|
||||
*
|
||||
* @param hre HardhatRuntimeEnvironment object.
|
||||
*/
|
||||
const deployYourCollectible: DeployFunction = async function (hre: HardhatRuntimeEnvironment) {
|
||||
/*
|
||||
On localhost, the deployer account is the one that comes with Hardhat, which is already funded.
|
||||
|
||||
When deploying to live networks (e.g `yarn deploy --network sepolia`), the deployer account
|
||||
should have sufficient balance to pay for the gas fees for contract creation.
|
||||
|
||||
You can generate a random account with `yarn generate` which will fill DEPLOYER_PRIVATE_KEY
|
||||
with a random private key in the .env file (then used on hardhat.config.ts)
|
||||
You can run the `yarn account` command to check your balance in every network.
|
||||
*/
|
||||
const { deployer } = await hre.getNamedAccounts();
|
||||
const { deploy } = hre.deployments;
|
||||
|
||||
await deploy("YourCollectible", {
|
||||
from: deployer,
|
||||
// Contract constructor arguments
|
||||
args: [],
|
||||
log: true,
|
||||
// autoMine: can be passed to the deploy function to make the deployment process faster on local networks by
|
||||
// automatically mining the contract deployment transaction. There is no effect on live networks.
|
||||
autoMine: true,
|
||||
});
|
||||
|
||||
// Get the deployed contract to interact with it after deploying.
|
||||
const yourCollectible = await hre.ethers.getContract<Contract>("YourCollectible", deployer);
|
||||
};
|
||||
|
||||
export default deployYourCollectible;
|
||||
|
||||
// Tags are useful if you have multiple deploy files and only want to run one of them.
|
||||
// e.g. yarn deploy --tags YourCollectible
|
||||
deployYourCollectible.tags = ["YourCollectible"];
|
||||
44
packages/hardhat/eslint.config.mjs
Normal file
44
packages/hardhat/eslint.config.mjs
Normal file
@@ -0,0 +1,44 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import globals from "globals";
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
import prettierPlugin from "eslint-plugin-prettier";
|
||||
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(["**/artifacts", "**/cache", "**/contracts", "**/node_modules/", "**/typechain-types", "**/*.json"]),
|
||||
{
|
||||
extends: compat.extends("plugin:@typescript-eslint/recommended", "prettier"),
|
||||
|
||||
plugins: {
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
|
||||
parser: tsParser,
|
||||
},
|
||||
|
||||
rules: {
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
{
|
||||
endOfLine: "auto",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
152
packages/hardhat/hardhat.config.ts
Normal file
152
packages/hardhat/hardhat.config.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { HardhatUserConfig } from "hardhat/config";
|
||||
import "@nomicfoundation/hardhat-ethers";
|
||||
import "@nomicfoundation/hardhat-chai-matchers";
|
||||
import "@typechain/hardhat";
|
||||
import "hardhat-gas-reporter";
|
||||
import "solidity-coverage";
|
||||
import "@nomicfoundation/hardhat-verify";
|
||||
import "hardhat-deploy";
|
||||
import "hardhat-deploy-ethers";
|
||||
import { task } from "hardhat/config";
|
||||
import generateTsAbis from "./scripts/generateTsAbis";
|
||||
|
||||
// If not set, it uses ours Alchemy's default API key.
|
||||
// You can get your own at https://dashboard.alchemyapi.io
|
||||
const providerApiKey = process.env.ALCHEMY_API_KEY || "oKxs-03sij-U_N0iOlrSsZFr29-IqbuF";
|
||||
// If not set, it uses the hardhat account 0 private key.
|
||||
// You can generate a random account with `yarn generate` or `yarn account:import` to import your existing PK
|
||||
const deployerPrivateKey =
|
||||
process.env.__RUNTIME_DEPLOYER_PRIVATE_KEY ?? "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
|
||||
// If not set, it uses our block explorers default API keys.
|
||||
const etherscanApiKey = process.env.ETHERSCAN_V2_API_KEY || "DNXJA8RX2Q3VZ4URQIWP7Z68CJXQZSC6AW";
|
||||
|
||||
const config: HardhatUserConfig = {
|
||||
solidity: {
|
||||
compilers: [
|
||||
{
|
||||
version: "0.8.20",
|
||||
settings: {
|
||||
optimizer: {
|
||||
enabled: true,
|
||||
// https://docs.soliditylang.org/en/latest/using-the-compiler.html#optimizer-options
|
||||
runs: 200,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
defaultNetwork: "localhost",
|
||||
namedAccounts: {
|
||||
deployer: {
|
||||
// By default, it will take the first Hardhat account as the deployer
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
networks: {
|
||||
// View the networks that are pre-configured.
|
||||
// If the network you are looking for is not here you can add new network settings
|
||||
hardhat: {
|
||||
forking: {
|
||||
url: `https://eth-mainnet.alchemyapi.io/v2/${providerApiKey}`,
|
||||
enabled: process.env.MAINNET_FORKING_ENABLED === "true",
|
||||
},
|
||||
},
|
||||
mainnet: {
|
||||
url: "https://mainnet.rpc.buidlguidl.com",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
sepolia: {
|
||||
url: `https://eth-sepolia.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
arbitrum: {
|
||||
url: `https://arb-mainnet.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
arbitrumSepolia: {
|
||||
url: `https://arb-sepolia.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
optimism: {
|
||||
url: `https://opt-mainnet.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
optimismSepolia: {
|
||||
url: `https://opt-sepolia.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
polygon: {
|
||||
url: `https://polygon-mainnet.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
polygonAmoy: {
|
||||
url: `https://polygon-amoy.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
polygonZkEvm: {
|
||||
url: `https://polygonzkevm-mainnet.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
polygonZkEvmCardona: {
|
||||
url: `https://polygonzkevm-cardona.g.alchemy.com/v2/${providerApiKey}`,
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
gnosis: {
|
||||
url: "https://rpc.gnosischain.com",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
chiado: {
|
||||
url: "https://rpc.chiadochain.net",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
base: {
|
||||
url: "https://mainnet.base.org",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
baseSepolia: {
|
||||
url: "https://sepolia.base.org",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
scrollSepolia: {
|
||||
url: "https://sepolia-rpc.scroll.io",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
scroll: {
|
||||
url: "https://rpc.scroll.io",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
celo: {
|
||||
url: "https://forno.celo.org",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
celoSepolia: {
|
||||
url: "https://forno.celo-sepolia.celo-testnet.org/",
|
||||
accounts: [deployerPrivateKey],
|
||||
},
|
||||
},
|
||||
// Configuration for harhdat-verify plugin
|
||||
etherscan: {
|
||||
apiKey: etherscanApiKey,
|
||||
},
|
||||
// Configuration for etherscan-verify from hardhat-deploy plugin
|
||||
verify: {
|
||||
etherscan: {
|
||||
apiKey: etherscanApiKey,
|
||||
},
|
||||
},
|
||||
sourcify: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
// Extend the deploy task
|
||||
task("deploy").setAction(async (args, hre, runSuper) => {
|
||||
// Run the original deploy task
|
||||
await runSuper(args);
|
||||
// Force run the generateTsAbis script
|
||||
await generateTsAbis(hre);
|
||||
});
|
||||
|
||||
export default config;
|
||||
63
packages/hardhat/package.json
Normal file
63
packages/hardhat/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@se-2/hardhat",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"account": "hardhat run scripts/listAccount.ts",
|
||||
"account:generate": "hardhat run scripts/generateAccount.ts",
|
||||
"account:import": "hardhat run scripts/importAccount.ts",
|
||||
"account:reveal-pk": "hardhat run scripts/revealPK.ts",
|
||||
"chain": "hardhat node --network hardhat --no-deploy",
|
||||
"check-types": "tsc --noEmit --incremental",
|
||||
"clean": "hardhat clean",
|
||||
"compile": "hardhat compile",
|
||||
"deploy": "ts-node scripts/runHardhatDeployWithPK.ts",
|
||||
"flatten": "hardhat flatten",
|
||||
"fork": "MAINNET_FORKING_ENABLED=true hardhat node --network hardhat --no-deploy",
|
||||
"format": "prettier --write './**/*.(ts|sol)'",
|
||||
"generate": "yarn account:generate",
|
||||
"hardhat-verify": "hardhat verify",
|
||||
"lint": "eslint",
|
||||
"lint-staged": "eslint",
|
||||
"test": "REPORT_GAS=true hardhat test --network hardhat",
|
||||
"verify": "hardhat etherscan-verify"
|
||||
},
|
||||
"dependencies": {
|
||||
"@inquirer/password": "^4.0.2",
|
||||
"@openzeppelin/contracts": "~5.0.2",
|
||||
"@typechain/ethers-v6": "~0.5.1",
|
||||
"dotenv": "~16.4.5",
|
||||
"envfile": "~7.1.0",
|
||||
"qrcode": "~1.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ethersproject/abi": "~5.7.0",
|
||||
"@ethersproject/providers": "~5.7.2",
|
||||
"@nomicfoundation/hardhat-chai-matchers": "~2.0.7",
|
||||
"@nomicfoundation/hardhat-ethers": "~3.0.8",
|
||||
"@nomicfoundation/hardhat-network-helpers": "~1.0.11",
|
||||
"@nomicfoundation/hardhat-verify": "~2.0.10",
|
||||
"@typechain/ethers-v5": "~11.1.2",
|
||||
"@typechain/hardhat": "~9.1.0",
|
||||
"@types/eslint": "~9.6.1",
|
||||
"@types/mocha": "~10.0.10",
|
||||
"@types/prettier": "~3.0.0",
|
||||
"@types/qrcode": "~1.5.5",
|
||||
"@typescript-eslint/eslint-plugin": "~8.27.0",
|
||||
"@typescript-eslint/parser": "~8.27.0",
|
||||
"chai": "~4.5.0",
|
||||
"eslint": "~9.23.0",
|
||||
"eslint-config-prettier": "~10.1.1",
|
||||
"eslint-plugin-prettier": "~5.2.4",
|
||||
"ethers": "~6.13.2",
|
||||
"hardhat": "~2.22.10",
|
||||
"hardhat-deploy": "^1.0.4",
|
||||
"hardhat-deploy-ethers": "~0.4.2",
|
||||
"hardhat-gas-reporter": "~2.2.1",
|
||||
"prettier": "^3.5.3",
|
||||
"prettier-plugin-solidity": "~1.4.1",
|
||||
"solidity-coverage": "~0.8.13",
|
||||
"ts-node": "~10.9.1",
|
||||
"typechain": "~8.3.2",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
58
packages/hardhat/scripts/generateAccount.ts
Normal file
58
packages/hardhat/scripts/generateAccount.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { ethers } from "ethers";
|
||||
import { parse, stringify } from "envfile";
|
||||
import * as fs from "fs";
|
||||
import password from "@inquirer/password";
|
||||
|
||||
const envFilePath = "./.env";
|
||||
|
||||
const getValidatedPassword = async () => {
|
||||
while (true) {
|
||||
const pass = await password({ message: "Enter a password to encrypt your private key:" });
|
||||
const confirmation = await password({ message: "Confirm password:" });
|
||||
|
||||
if (pass === confirmation) {
|
||||
return pass;
|
||||
}
|
||||
console.log("❌ Passwords don't match. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const setNewEnvConfig = async (existingEnvConfig = {}) => {
|
||||
console.log("👛 Generating new Wallet\n");
|
||||
const randomWallet = ethers.Wallet.createRandom();
|
||||
|
||||
const pass = await getValidatedPassword();
|
||||
const encryptedJson = await randomWallet.encrypt(pass);
|
||||
|
||||
const newEnvConfig = {
|
||||
...existingEnvConfig,
|
||||
DEPLOYER_PRIVATE_KEY_ENCRYPTED: encryptedJson,
|
||||
};
|
||||
|
||||
// Store in .env
|
||||
fs.writeFileSync(envFilePath, stringify(newEnvConfig));
|
||||
console.log("\n📄 Encrypted Private Key saved to packages/hardhat/.env file");
|
||||
console.log("🪄 Generated wallet address:", randomWallet.address, "\n");
|
||||
console.log("⚠️ Make sure to remember your password! You'll need it to decrypt the private key.");
|
||||
};
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(envFilePath)) {
|
||||
// No .env file yet.
|
||||
await setNewEnvConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingEnvConfig = parse(fs.readFileSync(envFilePath).toString());
|
||||
if (existingEnvConfig.DEPLOYER_PRIVATE_KEY_ENCRYPTED) {
|
||||
console.log("⚠️ You already have a deployer account. Check the packages/hardhat/.env file");
|
||||
return;
|
||||
}
|
||||
|
||||
await setNewEnvConfig(existingEnvConfig);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
136
packages/hardhat/scripts/generateTsAbis.ts
Normal file
136
packages/hardhat/scripts/generateTsAbis.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* DON'T MODIFY OR DELETE THIS SCRIPT (unless you know what you're doing)
|
||||
*
|
||||
* This script generates the file containing the contracts Abi definitions.
|
||||
* These definitions are used to derive the types needed in the custom scaffold-eth hooks, for example.
|
||||
* This script should run as the last deploy script.
|
||||
*/
|
||||
|
||||
import * as fs from "fs";
|
||||
import prettier from "prettier";
|
||||
import { DeployFunction } from "hardhat-deploy/types";
|
||||
|
||||
const generatedContractComment = `
|
||||
/**
|
||||
* This file is autogenerated by Scaffold-ETH.
|
||||
* You should not edit it manually or your changes might be overwritten.
|
||||
*/
|
||||
`;
|
||||
|
||||
const DEPLOYMENTS_DIR = "./deployments";
|
||||
const ARTIFACTS_DIR = "./artifacts";
|
||||
|
||||
function getDirectories(path: string) {
|
||||
return fs
|
||||
.readdirSync(path, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name);
|
||||
}
|
||||
|
||||
function getContractNames(path: string) {
|
||||
return fs
|
||||
.readdirSync(path, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isFile() && dirent.name.endsWith(".json"))
|
||||
.map(dirent => dirent.name.split(".")[0]);
|
||||
}
|
||||
|
||||
function getActualSourcesForContract(sources: Record<string, any>, contractName: string) {
|
||||
for (const sourcePath of Object.keys(sources)) {
|
||||
const sourceName = sourcePath.split("/").pop()?.split(".sol")[0];
|
||||
if (sourceName === contractName) {
|
||||
const contractContent = sources[sourcePath].content as string;
|
||||
const regex = /contract\s+(\w+)\s+is\s+([^{}]+)\{/;
|
||||
const match = contractContent.match(regex);
|
||||
|
||||
if (match) {
|
||||
const inheritancePart = match[2];
|
||||
// Split the inherited contracts by commas to get the list of inherited contracts
|
||||
const inheritedContracts = inheritancePart.split(",").map(contract => `${contract.trim()}.sol`);
|
||||
|
||||
return inheritedContracts;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function getInheritedFunctions(sources: Record<string, any>, contractName: string) {
|
||||
const actualSources = getActualSourcesForContract(sources, contractName);
|
||||
const inheritedFunctions = {} as Record<string, any>;
|
||||
|
||||
for (const sourceContractName of actualSources) {
|
||||
const sourcePath = Object.keys(sources).find(key => key.includes(`/${sourceContractName}`));
|
||||
if (sourcePath) {
|
||||
const sourceName = sourcePath?.split("/").pop()?.split(".sol")[0];
|
||||
const { abi } = JSON.parse(fs.readFileSync(`${ARTIFACTS_DIR}/${sourcePath}/${sourceName}.json`).toString());
|
||||
for (const functionAbi of abi) {
|
||||
if (functionAbi.type === "function") {
|
||||
inheritedFunctions[functionAbi.name] = sourcePath;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inheritedFunctions;
|
||||
}
|
||||
|
||||
function getContractDataFromDeployments() {
|
||||
if (!fs.existsSync(DEPLOYMENTS_DIR)) {
|
||||
throw Error("At least one other deployment script should exist to generate an actual contract.");
|
||||
}
|
||||
const output = {} as Record<string, any>;
|
||||
const chainDirectories = getDirectories(DEPLOYMENTS_DIR);
|
||||
for (const chainName of chainDirectories) {
|
||||
let chainId;
|
||||
try {
|
||||
chainId = fs.readFileSync(`${DEPLOYMENTS_DIR}/${chainName}/.chainId`).toString();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (error) {
|
||||
console.log(`No chainId file found for ${chainName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const contracts = {} as Record<string, any>;
|
||||
for (const contractName of getContractNames(`${DEPLOYMENTS_DIR}/${chainName}`)) {
|
||||
const { abi, address, metadata, receipt } = JSON.parse(
|
||||
fs.readFileSync(`${DEPLOYMENTS_DIR}/${chainName}/${contractName}.json`).toString(),
|
||||
);
|
||||
const inheritedFunctions = metadata ? getInheritedFunctions(JSON.parse(metadata).sources, contractName) : {};
|
||||
contracts[contractName] = { address, abi, inheritedFunctions, deployedOnBlock: receipt?.blockNumber };
|
||||
}
|
||||
output[chainId] = contracts;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the TypeScript contract definition file based on the json output of the contract deployment scripts
|
||||
* This script should be run last.
|
||||
*/
|
||||
const generateTsAbis: DeployFunction = async function () {
|
||||
const TARGET_DIR = "../nextjs/contracts/";
|
||||
const allContractsData = getContractDataFromDeployments();
|
||||
|
||||
const fileContent = Object.entries(allContractsData).reduce((content, [chainId, chainConfig]) => {
|
||||
return `${content}${parseInt(chainId).toFixed(0)}:${JSON.stringify(chainConfig, null, 2)},`;
|
||||
}, "");
|
||||
|
||||
if (!fs.existsSync(TARGET_DIR)) {
|
||||
fs.mkdirSync(TARGET_DIR);
|
||||
}
|
||||
fs.writeFileSync(
|
||||
`${TARGET_DIR}deployedContracts.ts`,
|
||||
await prettier.format(
|
||||
`${generatedContractComment} import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract"; \n\n
|
||||
const deployedContracts = {${fileContent}} as const; \n\n export default deployedContracts satisfies GenericContractsDeclaration`,
|
||||
{
|
||||
parser: "typescript",
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
console.log(`📝 Updated TypeScript contract definition file on ${TARGET_DIR}deployedContracts.ts`);
|
||||
};
|
||||
|
||||
export default generateTsAbis;
|
||||
72
packages/hardhat/scripts/importAccount.ts
Normal file
72
packages/hardhat/scripts/importAccount.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { ethers } from "ethers";
|
||||
import { parse, stringify } from "envfile";
|
||||
import * as fs from "fs";
|
||||
import password from "@inquirer/password";
|
||||
|
||||
const envFilePath = "./.env";
|
||||
|
||||
const getValidatedPassword = async () => {
|
||||
while (true) {
|
||||
const pass = await password({ message: "Enter a password to encrypt your private key:" });
|
||||
const confirmation = await password({ message: "Confirm password:" });
|
||||
|
||||
if (pass === confirmation) {
|
||||
return pass;
|
||||
}
|
||||
console.log("❌ Passwords don't match. Please try again.");
|
||||
}
|
||||
};
|
||||
|
||||
const getWalletFromPrivateKey = async () => {
|
||||
while (true) {
|
||||
const privateKey = await password({ message: "Paste your private key:" });
|
||||
try {
|
||||
const wallet = new ethers.Wallet(privateKey);
|
||||
return wallet;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.log("❌ Invalid private key format. Please try again.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const setNewEnvConfig = async (existingEnvConfig = {}) => {
|
||||
console.log("👛 Importing Wallet\n");
|
||||
|
||||
const wallet = await getWalletFromPrivateKey();
|
||||
|
||||
const pass = await getValidatedPassword();
|
||||
const encryptedJson = await wallet.encrypt(pass);
|
||||
|
||||
const newEnvConfig = {
|
||||
...existingEnvConfig,
|
||||
DEPLOYER_PRIVATE_KEY_ENCRYPTED: encryptedJson,
|
||||
};
|
||||
|
||||
// Store in .env
|
||||
fs.writeFileSync(envFilePath, stringify(newEnvConfig));
|
||||
console.log("\n📄 Encrypted Private Key saved to packages/hardhat/.env file");
|
||||
console.log("🪄 Imported wallet address:", wallet.address, "\n");
|
||||
console.log("⚠️ Make sure to remember your password! You'll need it to decrypt the private key.");
|
||||
};
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(envFilePath)) {
|
||||
// No .env file yet.
|
||||
await setNewEnvConfig();
|
||||
return;
|
||||
}
|
||||
|
||||
const existingEnvConfig = parse(fs.readFileSync(envFilePath).toString());
|
||||
if (existingEnvConfig.DEPLOYER_PRIVATE_KEY_ENCRYPTED) {
|
||||
console.log("⚠️ You already have a deployer account. Check the packages/hardhat/.env file");
|
||||
return;
|
||||
}
|
||||
|
||||
await setNewEnvConfig(existingEnvConfig);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
52
packages/hardhat/scripts/listAccount.ts
Normal file
52
packages/hardhat/scripts/listAccount.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { ethers, Wallet } from "ethers";
|
||||
import QRCode from "qrcode";
|
||||
import { config } from "hardhat";
|
||||
import password from "@inquirer/password";
|
||||
|
||||
async function main() {
|
||||
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
|
||||
|
||||
if (!encryptedKey) {
|
||||
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
|
||||
return;
|
||||
}
|
||||
|
||||
const pass = await password({ message: "Enter your password to decrypt the private key:" });
|
||||
let wallet: Wallet;
|
||||
try {
|
||||
wallet = (await Wallet.fromEncryptedJson(encryptedKey, pass)) as Wallet;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.log("❌ Failed to decrypt private key. Wrong password?");
|
||||
return;
|
||||
}
|
||||
|
||||
const address = wallet.address;
|
||||
console.log(await QRCode.toString(address, { type: "terminal", small: true }));
|
||||
console.log("Public address:", address, "\n");
|
||||
|
||||
// Balance on each network
|
||||
const availableNetworks = config.networks;
|
||||
for (const networkName in availableNetworks) {
|
||||
try {
|
||||
const network = availableNetworks[networkName];
|
||||
if (!("url" in network)) continue;
|
||||
const provider = new ethers.JsonRpcProvider(network.url);
|
||||
await provider._detectNetwork();
|
||||
const balance = await provider.getBalance(address);
|
||||
console.log("--", networkName, "-- 📡");
|
||||
console.log(" balance:", +ethers.formatEther(balance));
|
||||
console.log(" nonce:", +(await provider.getTransactionCount(address)));
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.log("Can't connect to network", networkName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
31
packages/hardhat/scripts/revealPK.ts
Normal file
31
packages/hardhat/scripts/revealPK.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { Wallet } from "ethers";
|
||||
import password from "@inquirer/password";
|
||||
|
||||
async function main() {
|
||||
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
|
||||
|
||||
if (!encryptedKey) {
|
||||
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("👀 This will reveal your private key on the console.\n");
|
||||
|
||||
const pass = await password({ message: "Enter your password to decrypt the private key:" });
|
||||
let wallet: Wallet;
|
||||
try {
|
||||
wallet = (await Wallet.fromEncryptedJson(encryptedKey, pass)) as Wallet;
|
||||
} catch {
|
||||
console.log("❌ Failed to decrypt private key. Wrong password?");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("\n🔑 Private key:", wallet.privateKey);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
58
packages/hardhat/scripts/runHardhatDeployWithPK.ts
Normal file
58
packages/hardhat/scripts/runHardhatDeployWithPK.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import * as dotenv from "dotenv";
|
||||
dotenv.config();
|
||||
import { Wallet } from "ethers";
|
||||
import password from "@inquirer/password";
|
||||
import { spawn } from "child_process";
|
||||
import { config } from "hardhat";
|
||||
|
||||
/**
|
||||
* Unencrypts the private key and runs the hardhat deploy command
|
||||
*/
|
||||
async function main() {
|
||||
const networkIndex = process.argv.indexOf("--network");
|
||||
const networkName = networkIndex !== -1 ? process.argv[networkIndex + 1] : config.defaultNetwork;
|
||||
|
||||
if (networkName === "localhost" || networkName === "hardhat") {
|
||||
// Deploy command on the localhost network
|
||||
const hardhat = spawn("hardhat", ["deploy", ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
hardhat.on("exit", code => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const encryptedKey = process.env.DEPLOYER_PRIVATE_KEY_ENCRYPTED;
|
||||
|
||||
if (!encryptedKey) {
|
||||
console.log("🚫️ You don't have a deployer account. Run `yarn generate` or `yarn account:import` first");
|
||||
return;
|
||||
}
|
||||
|
||||
const pass = await password({ message: "Enter password to decrypt private key:" });
|
||||
|
||||
try {
|
||||
const wallet = await Wallet.fromEncryptedJson(encryptedKey, pass);
|
||||
process.env.__RUNTIME_DEPLOYER_PRIVATE_KEY = wallet.privateKey;
|
||||
|
||||
const hardhat = spawn("hardhat", ["deploy", ...process.argv.slice(2)], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
shell: process.platform === "win32",
|
||||
});
|
||||
|
||||
hardhat.on("exit", code => {
|
||||
process.exit(code || 0);
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
console.error("Failed to decrypt private key. Wrong password?");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
2
packages/hardhat/test/.gitkeep
Normal file
2
packages/hardhat/test/.gitkeep
Normal file
@@ -0,0 +1,2 @@
|
||||
# Write tests for your smart contract in this directory
|
||||
# Example: YourContract.ts
|
||||
58
packages/hardhat/test/YourCollectible.ts
Normal file
58
packages/hardhat/test/YourCollectible.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
//
|
||||
// This script executes when you run 'yarn test'
|
||||
//
|
||||
|
||||
import { ethers } from "hardhat";
|
||||
import { expect } from "chai";
|
||||
import { YourCollectible } from "../typechain-types";
|
||||
|
||||
describe("🚩 Challenge: 🎟 Tokenization 🤓", function () {
|
||||
let myContract: YourCollectible;
|
||||
|
||||
describe("YourCollectible", function () {
|
||||
const contractAddress = process.env.CONTRACT_ADDRESS;
|
||||
|
||||
let contractArtifact: string;
|
||||
if (contractAddress) {
|
||||
// For the autograder
|
||||
contractArtifact = `contracts/download-${contractAddress}.sol:YourCollectible`;
|
||||
} else {
|
||||
contractArtifact = "contracts/YourCollectible.sol:YourCollectible";
|
||||
}
|
||||
|
||||
it("Should deploy the contract", async function () {
|
||||
const YourCollectible = await ethers.getContractFactory(contractArtifact);
|
||||
myContract = await YourCollectible.deploy();
|
||||
console.log("\t", " 🛰 Contract deployed on", await myContract.getAddress());
|
||||
});
|
||||
|
||||
describe("mintItem()", function () {
|
||||
it("Should be able to mint an NFT", async function () {
|
||||
const [owner] = await ethers.getSigners();
|
||||
|
||||
console.log("\t", " 🧑🏫 Tester Address: ", owner.address);
|
||||
|
||||
const startingBalance = await myContract.balanceOf(owner.address);
|
||||
console.log("\t", " ⚖️ Starting balance: ", Number(startingBalance));
|
||||
|
||||
console.log("\t", " 🔨 Minting...");
|
||||
const mintResult = await myContract.mintItem(owner.address, "QmfVMAmNM1kDEBYrC2TPzQDoCRFH6F5tE1e9Mr4FkkR5Xr");
|
||||
console.log("\t", " 🏷 mint tx: ", mintResult.hash);
|
||||
|
||||
console.log("\t", " ⏳ Waiting for confirmation...");
|
||||
const txResult = await mintResult.wait();
|
||||
expect(txResult?.status).to.equal(1);
|
||||
|
||||
console.log("\t", " 🔎 Checking new balance: ", Number(startingBalance));
|
||||
expect(await myContract.balanceOf(owner.address)).to.equal(startingBalance + 1n);
|
||||
});
|
||||
|
||||
it("Should track tokens of owner by index", async function () {
|
||||
const [owner] = await ethers.getSigners();
|
||||
const startingBalance = await myContract.balanceOf(owner.address);
|
||||
const token = await myContract.tokenOfOwnerByIndex(owner.address, startingBalance - 1n);
|
||||
expect(token).to.greaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
11
packages/hardhat/tsconfig.json
Normal file
11
packages/hardhat/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2020",
|
||||
"module": "commonjs",
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
14
packages/nextjs/.env.example
Normal file
14
packages/nextjs/.env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Template for NextJS environment variables.
|
||||
|
||||
# For local development, copy this file, rename it to .env.local, and fill in the values.
|
||||
# When deploying live, you'll need to store the vars in Vercel/System config.
|
||||
|
||||
# If not set, we provide default values (check `scaffold.config.ts`) so developers can start prototyping out of the box,
|
||||
# but we recommend getting your own API Keys for Production Apps.
|
||||
|
||||
# To access the values stored in this env file you can use: process.env.VARIABLENAME
|
||||
# You'll need to prefix the variables names with NEXT_PUBLIC_ if you want to access them on the client side.
|
||||
# More info: https://nextjs.org/docs/pages/building-your-application/configuring/environment-variables
|
||||
NEXT_PUBLIC_ALCHEMY_API_KEY=
|
||||
NEXT_PUBLIC_WALLET_CONNECT_PROJECT_ID=
|
||||
|
||||
38
packages/nextjs/.gitignore
vendored
Normal file
38
packages/nextjs/.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
.vercel
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
ipfs-upload.config.json
|
||||
9
packages/nextjs/.prettierrc.js
Normal file
9
packages/nextjs/.prettierrc.js
Normal file
@@ -0,0 +1,9 @@
|
||||
module.exports = {
|
||||
arrowParens: "avoid",
|
||||
printWidth: 120,
|
||||
tabWidth: 2,
|
||||
trailingComma: "all",
|
||||
importOrder: ["^react$", "^next/(.*)$", "<THIRD_PARTY_MODULES>", "^@heroicons/(.*)$", "^~~/(.*)$"],
|
||||
importOrderSortSpecifiers: true,
|
||||
plugins: [require.resolve("@trivago/prettier-plugin-sort-imports")],
|
||||
};
|
||||
14
packages/nextjs/app/api/ipfs/add/route.ts
Normal file
14
packages/nextjs/app/api/ipfs/add/route.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
"use server";
|
||||
|
||||
import { ipfsClient } from "~~/utils/tokenization/ipfs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const res = await ipfsClient.add(JSON.stringify(body));
|
||||
return Response.json(res);
|
||||
} catch (error) {
|
||||
console.log("Error adding to ipfs", error);
|
||||
return Response.json({ error: "Error adding to ipfs" });
|
||||
}
|
||||
}
|
||||
12
packages/nextjs/app/api/ipfs/get-metadata/route.ts
Normal file
12
packages/nextjs/app/api/ipfs/get-metadata/route.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getNFTMetadataFromIPFS } from "~~/utils/tokenization/ipfs";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const { ipfsHash } = await request.json();
|
||||
const res = await getNFTMetadataFromIPFS(ipfsHash);
|
||||
return Response.json(res);
|
||||
} catch (error) {
|
||||
console.log("Error getting metadata from ipfs", error);
|
||||
return Response.json({ error: "Error getting metadata from ipfs" });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
type AddressCodeTabProps = {
|
||||
bytecode: string;
|
||||
assembly: string;
|
||||
};
|
||||
|
||||
export const AddressCodeTab = ({ bytecode, assembly }: AddressCodeTabProps) => {
|
||||
const formattedAssembly = Array.from(assembly.matchAll(/\w+( 0x[a-fA-F0-9]+)?/g))
|
||||
.map(it => it[0])
|
||||
.join("\n");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
Bytecode
|
||||
<div className="mockup-code -indent-5 overflow-y-auto max-h-[500px]">
|
||||
<pre className="px-5">
|
||||
<code className="whitespace-pre-wrap overflow-auto break-words">{bytecode}</code>
|
||||
</pre>
|
||||
</div>
|
||||
Opcodes
|
||||
<div className="mockup-code -indent-5 overflow-y-auto max-h-[500px]">
|
||||
<pre className="px-5">
|
||||
<code>{formattedAssembly}</code>
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BackButton } from "./BackButton";
|
||||
import { ContractTabs } from "./ContractTabs";
|
||||
import { Address as AddressType } from "viem";
|
||||
import { Address, Balance } from "~~/components/scaffold-eth";
|
||||
|
||||
export const AddressComponent = ({
|
||||
address,
|
||||
contractData,
|
||||
}: {
|
||||
address: AddressType;
|
||||
contractData: { bytecode: string; assembly: string } | null;
|
||||
}) => {
|
||||
return (
|
||||
<div className="m-10 mb-20">
|
||||
<div className="flex justify-start mb-5">
|
||||
<BackButton />
|
||||
</div>
|
||||
<div className="col-span-5 grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-10">
|
||||
<div className="col-span-1 flex flex-col">
|
||||
<div className="bg-base-100 border-base-300 border shadow-md shadow-secondary rounded-3xl px-6 lg:px-8 mb-6 space-y-1 py-4 overflow-x-auto">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Address address={address} format="long" onlyEnsOrAddress />
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="font-bold text-sm">Balance:</span>
|
||||
<Balance address={address} className="text" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ContractTabs address={address} contractData={contractData} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Address } from "viem";
|
||||
import { useContractLogs } from "~~/hooks/scaffold-eth";
|
||||
import { replacer } from "~~/utils/scaffold-eth/common";
|
||||
|
||||
export const AddressLogsTab = ({ address }: { address: Address }) => {
|
||||
const contractLogs = useContractLogs(address);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
<div className="mockup-code overflow-auto max-h-[500px]">
|
||||
<pre className="px-5 whitespace-pre-wrap break-words">
|
||||
{contractLogs.map((log, i) => (
|
||||
<div key={i}>
|
||||
<strong>Log:</strong> {JSON.stringify(log, replacer, 2)}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Address, createPublicClient, http, toHex } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
|
||||
const publicClient = createPublicClient({
|
||||
chain: hardhat,
|
||||
transport: http(),
|
||||
});
|
||||
|
||||
export const AddressStorageTab = ({ address }: { address: Address }) => {
|
||||
const [storage, setStorage] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStorage = async () => {
|
||||
try {
|
||||
const storageData = [];
|
||||
let idx = 0;
|
||||
|
||||
while (true) {
|
||||
const storageAtPosition = await publicClient.getStorageAt({
|
||||
address: address,
|
||||
slot: toHex(idx),
|
||||
});
|
||||
|
||||
if (storageAtPosition === "0x" + "0".repeat(64)) break;
|
||||
|
||||
if (storageAtPosition) {
|
||||
storageData.push(storageAtPosition);
|
||||
}
|
||||
|
||||
idx++;
|
||||
}
|
||||
setStorage(storageData);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch storage:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStorage();
|
||||
}, [address]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4">
|
||||
{storage.length > 0 ? (
|
||||
<div className="mockup-code overflow-auto max-h-[500px]">
|
||||
<pre className="px-5 whitespace-pre-wrap break-words">
|
||||
{storage.map((data, i) => (
|
||||
<div key={i}>
|
||||
<strong>Storage Slot {i}:</strong> {data}
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-lg">This contract does not have any variables.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
12
packages/nextjs/app/blockexplorer/_components/BackButton.tsx
Normal file
12
packages/nextjs/app/blockexplorer/_components/BackButton.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export const BackButton = () => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<button className="btn btn-sm btn-primary" onClick={() => router.back()}>
|
||||
Back
|
||||
</button>
|
||||
);
|
||||
};
|
||||
102
packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx
Normal file
102
packages/nextjs/app/blockexplorer/_components/ContractTabs.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { AddressCodeTab } from "./AddressCodeTab";
|
||||
import { AddressLogsTab } from "./AddressLogsTab";
|
||||
import { AddressStorageTab } from "./AddressStorageTab";
|
||||
import { PaginationButton } from "./PaginationButton";
|
||||
import { TransactionsTable } from "./TransactionsTable";
|
||||
import { Address, createPublicClient, http } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useFetchBlocks } from "~~/hooks/scaffold-eth";
|
||||
|
||||
type AddressCodeTabProps = {
|
||||
bytecode: string;
|
||||
assembly: string;
|
||||
};
|
||||
|
||||
type PageProps = {
|
||||
address: Address;
|
||||
contractData: AddressCodeTabProps | null;
|
||||
};
|
||||
|
||||
const publicClient = createPublicClient({
|
||||
chain: hardhat,
|
||||
transport: http(),
|
||||
});
|
||||
|
||||
export const ContractTabs = ({ address, contractData }: PageProps) => {
|
||||
const { blocks, transactionReceipts, currentPage, totalBlocks, setCurrentPage } = useFetchBlocks();
|
||||
const [activeTab, setActiveTab] = useState("transactions");
|
||||
const [isContract, setIsContract] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkIsContract = async () => {
|
||||
const contractCode = await publicClient.getBytecode({ address: address });
|
||||
setIsContract(contractCode !== undefined && contractCode !== "0x");
|
||||
};
|
||||
|
||||
checkIsContract();
|
||||
}, [address]);
|
||||
|
||||
const filteredBlocks = blocks.filter(block =>
|
||||
block.transactions.some(tx => {
|
||||
if (typeof tx === "string") {
|
||||
return false;
|
||||
}
|
||||
return tx.from.toLowerCase() === address.toLowerCase() || tx.to?.toLowerCase() === address.toLowerCase();
|
||||
}),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isContract && (
|
||||
<div role="tablist" className="tabs tabs-lift">
|
||||
<button
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "transactions" ? "tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("transactions")}
|
||||
>
|
||||
Transactions
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "code" ? "tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("code")}
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "storage" ? "tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("storage")}
|
||||
>
|
||||
Storage
|
||||
</button>
|
||||
<button
|
||||
role="tab"
|
||||
className={`tab ${activeTab === "logs" ? "tab-active" : ""}`}
|
||||
onClick={() => setActiveTab("logs")}
|
||||
>
|
||||
Logs
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "transactions" && (
|
||||
<div className="pt-4">
|
||||
<TransactionsTable blocks={filteredBlocks} transactionReceipts={transactionReceipts} />
|
||||
<PaginationButton
|
||||
currentPage={currentPage}
|
||||
totalItems={Number(totalBlocks)}
|
||||
setCurrentPage={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{activeTab === "code" && contractData && (
|
||||
<AddressCodeTab bytecode={contractData.bytecode} assembly={contractData.assembly} />
|
||||
)}
|
||||
{activeTab === "storage" && <AddressStorageTab address={address} />}
|
||||
{activeTab === "logs" && <AddressLogsTab address={address} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
type PaginationButtonProps = {
|
||||
currentPage: number;
|
||||
totalItems: number;
|
||||
setCurrentPage: (page: number) => void;
|
||||
};
|
||||
|
||||
const ITEMS_PER_PAGE = 20;
|
||||
|
||||
export const PaginationButton = ({ currentPage, totalItems, setCurrentPage }: PaginationButtonProps) => {
|
||||
const isPrevButtonDisabled = currentPage === 0;
|
||||
const isNextButtonDisabled = currentPage + 1 >= Math.ceil(totalItems / ITEMS_PER_PAGE);
|
||||
|
||||
const prevButtonClass = isPrevButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
|
||||
const nextButtonClass = isNextButtonDisabled ? "btn-disabled cursor-default" : "btn-primary";
|
||||
|
||||
if (isNextButtonDisabled && isPrevButtonDisabled) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-5 justify-end flex gap-3 mx-5">
|
||||
<button
|
||||
className={`btn btn-sm ${prevButtonClass}`}
|
||||
disabled={isPrevButtonDisabled}
|
||||
onClick={() => setCurrentPage(currentPage - 1)}
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="self-center text-primary-content font-medium">Page {currentPage + 1}</span>
|
||||
<button
|
||||
className={`btn btn-sm ${nextButtonClass}`}
|
||||
disabled={isNextButtonDisabled}
|
||||
onClick={() => setCurrentPage(currentPage + 1)}
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
49
packages/nextjs/app/blockexplorer/_components/SearchBar.tsx
Normal file
49
packages/nextjs/app/blockexplorer/_components/SearchBar.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isAddress, isHex } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { usePublicClient } from "wagmi";
|
||||
|
||||
export const SearchBar = () => {
|
||||
const [searchInput, setSearchInput] = useState("");
|
||||
const router = useRouter();
|
||||
|
||||
const client = usePublicClient({ chainId: hardhat.id });
|
||||
|
||||
const handleSearch = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (isHex(searchInput)) {
|
||||
try {
|
||||
const tx = await client?.getTransaction({ hash: searchInput });
|
||||
if (tx) {
|
||||
router.push(`/blockexplorer/transaction/${searchInput}`);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch transaction:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (isAddress(searchInput)) {
|
||||
router.push(`/blockexplorer/address/${searchInput}`);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSearch} className="flex items-center justify-end mb-5 space-x-3 mx-5">
|
||||
<input
|
||||
className="border-primary bg-base-100 text-base-content placeholder:text-base-content/50 p-2 mr-2 w-full md:w-1/2 lg:w-1/3 rounded-md shadow-md focus:outline-hidden focus:ring-2 focus:ring-accent"
|
||||
type="text"
|
||||
value={searchInput}
|
||||
placeholder="Search by hash or address"
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
/>
|
||||
<button className="btn btn-sm btn-primary" type="submit">
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
|
||||
import { useCopyToClipboard } from "~~/hooks/scaffold-eth/useCopyToClipboard";
|
||||
|
||||
export const TransactionHash = ({ hash }: { hash: string }) => {
|
||||
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
|
||||
useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<Link href={`/blockexplorer/transaction/${hash}`}>
|
||||
{hash?.substring(0, 6)}...{hash?.substring(hash.length - 4)}
|
||||
</Link>
|
||||
{isAddressCopiedToClipboard ? (
|
||||
<CheckCircleIcon
|
||||
className="ml-1.5 text-xl font-normal text-base-content h-5 w-5 cursor-pointer"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<DocumentDuplicateIcon
|
||||
className="ml-1.5 text-xl font-normal h-5 w-5 cursor-pointer"
|
||||
aria-hidden="true"
|
||||
onClick={() => copyAddressToClipboard(hash)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
import { TransactionHash } from "./TransactionHash";
|
||||
import { formatEther } from "viem";
|
||||
import { Address } from "~~/components/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { TransactionWithFunction } from "~~/utils/scaffold-eth";
|
||||
import { TransactionsTableProps } from "~~/utils/scaffold-eth/";
|
||||
|
||||
export const TransactionsTable = ({ blocks, transactionReceipts }: TransactionsTableProps) => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
|
||||
return (
|
||||
<div className="flex justify-center px-4 md:px-0">
|
||||
<div className="overflow-x-auto w-full shadow-2xl rounded-xl">
|
||||
<table className="table text-xl bg-base-100 table-zebra w-full md:table-md table-sm">
|
||||
<thead>
|
||||
<tr className="rounded-xl text-sm text-base-content">
|
||||
<th className="bg-primary">Transaction Hash</th>
|
||||
<th className="bg-primary">Function Called</th>
|
||||
<th className="bg-primary">Block Number</th>
|
||||
<th className="bg-primary">Time Mined</th>
|
||||
<th className="bg-primary">From</th>
|
||||
<th className="bg-primary">To</th>
|
||||
<th className="bg-primary text-end">Value ({targetNetwork.nativeCurrency.symbol})</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{blocks.map(block =>
|
||||
(block.transactions as TransactionWithFunction[]).map(tx => {
|
||||
const receipt = transactionReceipts[tx.hash];
|
||||
const timeMined = new Date(Number(block.timestamp) * 1000).toLocaleString();
|
||||
const functionCalled = tx.input.substring(0, 10);
|
||||
|
||||
return (
|
||||
<tr key={tx.hash} className="hover text-sm">
|
||||
<td className="w-1/12 md:py-4">
|
||||
<TransactionHash hash={tx.hash} />
|
||||
</td>
|
||||
<td className="w-2/12 md:py-4">
|
||||
{tx.functionName === "0x" ? "" : <span className="mr-1">{tx.functionName}</span>}
|
||||
{functionCalled !== "0x" && (
|
||||
<span className="badge badge-primary font-bold text-xs">{functionCalled}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="w-1/12 md:py-4">{block.number?.toString()}</td>
|
||||
<td className="w-2/12 md:py-4">{timeMined}</td>
|
||||
<td className="w-2/12 md:py-4">
|
||||
<Address address={tx.from} size="sm" onlyEnsOrAddress />
|
||||
</td>
|
||||
<td className="w-2/12 md:py-4">
|
||||
{!receipt?.contractAddress ? (
|
||||
tx.to && <Address address={tx.to} size="sm" onlyEnsOrAddress />
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Address address={receipt.contractAddress} size="sm" onlyEnsOrAddress />
|
||||
<small className="absolute top-4 left-4">(Contract Creation)</small>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="text-right md:py-4">
|
||||
{formatEther(tx.value)} {targetNetwork.nativeCurrency.symbol}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}),
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
7
packages/nextjs/app/blockexplorer/_components/index.tsx
Normal file
7
packages/nextjs/app/blockexplorer/_components/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from "./SearchBar";
|
||||
export * from "./BackButton";
|
||||
export * from "./AddressCodeTab";
|
||||
export * from "./TransactionHash";
|
||||
export * from "./ContractTabs";
|
||||
export * from "./PaginationButton";
|
||||
export * from "./TransactionsTable";
|
||||
101
packages/nextjs/app/blockexplorer/address/[address]/page.tsx
Normal file
101
packages/nextjs/app/blockexplorer/address/[address]/page.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { Address } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { AddressComponent } from "~~/app/blockexplorer/_components/AddressComponent";
|
||||
import deployedContracts from "~~/contracts/deployedContracts";
|
||||
import { isZeroAddress } from "~~/utils/scaffold-eth/common";
|
||||
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ address: Address }>;
|
||||
};
|
||||
|
||||
async function fetchByteCodeAndAssembly(buildInfoDirectory: string, contractPath: string) {
|
||||
const buildInfoFiles = fs.readdirSync(buildInfoDirectory);
|
||||
let bytecode = "";
|
||||
let assembly = "";
|
||||
|
||||
for (let i = 0; i < buildInfoFiles.length; i++) {
|
||||
const filePath = path.join(buildInfoDirectory, buildInfoFiles[i]);
|
||||
|
||||
const buildInfo = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
||||
|
||||
if (buildInfo.output.contracts[contractPath]) {
|
||||
for (const contract in buildInfo.output.contracts[contractPath]) {
|
||||
bytecode = buildInfo.output.contracts[contractPath][contract].evm.bytecode.object;
|
||||
assembly = buildInfo.output.contracts[contractPath][contract].evm.bytecode.opcodes;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (bytecode && assembly) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { bytecode, assembly };
|
||||
}
|
||||
|
||||
const getContractData = async (address: Address) => {
|
||||
const contracts = deployedContracts as GenericContractsDeclaration | null;
|
||||
const chainId = hardhat.id;
|
||||
|
||||
if (!contracts || !contracts[chainId] || Object.keys(contracts[chainId]).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let contractPath = "";
|
||||
|
||||
const buildInfoDirectory = path.join(
|
||||
__dirname,
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"..",
|
||||
"hardhat",
|
||||
"artifacts",
|
||||
"build-info",
|
||||
);
|
||||
|
||||
if (!fs.existsSync(buildInfoDirectory)) {
|
||||
throw new Error(`Directory ${buildInfoDirectory} not found.`);
|
||||
}
|
||||
|
||||
const deployedContractsOnChain = contracts[chainId];
|
||||
for (const [contractName, contractInfo] of Object.entries(deployedContractsOnChain)) {
|
||||
if (contractInfo.address.toLowerCase() === address.toLowerCase()) {
|
||||
contractPath = `contracts/${contractName}.sol`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!contractPath) {
|
||||
// No contract found at this address
|
||||
return null;
|
||||
}
|
||||
|
||||
const { bytecode, assembly } = await fetchByteCodeAndAssembly(buildInfoDirectory, contractPath);
|
||||
|
||||
return { bytecode, assembly };
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
// An workaround to enable static exports in Next.js, generating single dummy page.
|
||||
return [{ address: "0x0000000000000000000000000000000000000000" }];
|
||||
}
|
||||
|
||||
const AddressPage = async (props: PageProps) => {
|
||||
const params = await props.params;
|
||||
const address = params?.address as Address;
|
||||
|
||||
if (isZeroAddress(address)) return null;
|
||||
|
||||
const contractData: { bytecode: string; assembly: string } | null = await getContractData(address);
|
||||
return <AddressComponent address={address} contractData={contractData} />;
|
||||
};
|
||||
|
||||
export default AddressPage;
|
||||
12
packages/nextjs/app/blockexplorer/layout.tsx
Normal file
12
packages/nextjs/app/blockexplorer/layout.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
|
||||
|
||||
export const metadata = getMetadata({
|
||||
title: "Block Explorer",
|
||||
description: "Block Explorer created with 🏗 Scaffold-ETH 2",
|
||||
});
|
||||
|
||||
const BlockExplorerLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
export default BlockExplorerLayout;
|
||||
83
packages/nextjs/app/blockexplorer/page.tsx
Normal file
83
packages/nextjs/app/blockexplorer/page.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { PaginationButton, SearchBar, TransactionsTable } from "./_components";
|
||||
import type { NextPage } from "next";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useFetchBlocks } from "~~/hooks/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
const BlockExplorer: NextPage = () => {
|
||||
const { blocks, transactionReceipts, currentPage, totalBlocks, setCurrentPage, error } = useFetchBlocks();
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const [isLocalNetwork, setIsLocalNetwork] = useState(true);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetNetwork.id !== hardhat.id) {
|
||||
setIsLocalNetwork(false);
|
||||
}
|
||||
}, [targetNetwork.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (targetNetwork.id === hardhat.id && error) {
|
||||
setHasError(true);
|
||||
}
|
||||
}, [targetNetwork.id, error]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLocalNetwork) {
|
||||
notification.error(
|
||||
<>
|
||||
<p className="font-bold mt-0 mb-1">
|
||||
<code className="italic bg-base-300 text-base font-bold"> targetNetwork </code> is not localhost
|
||||
</p>
|
||||
<p className="m-0">
|
||||
- You are on <code className="italic bg-base-300 text-base font-bold">{targetNetwork.name}</code> .This
|
||||
block explorer is only for <code className="italic bg-base-300 text-base font-bold">localhost</code>.
|
||||
</p>
|
||||
<p className="mt-1 break-normal">
|
||||
- You can use{" "}
|
||||
<a className="text-accent" href={targetNetwork.blockExplorers?.default.url}>
|
||||
{targetNetwork.blockExplorers?.default.name}
|
||||
</a>{" "}
|
||||
instead
|
||||
</p>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
}, [
|
||||
isLocalNetwork,
|
||||
targetNetwork.blockExplorers?.default.name,
|
||||
targetNetwork.blockExplorers?.default.url,
|
||||
targetNetwork.name,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasError) {
|
||||
notification.error(
|
||||
<>
|
||||
<p className="font-bold mt-0 mb-1">Cannot connect to local provider</p>
|
||||
<p className="m-0">
|
||||
- Did you forget to run <code className="italic bg-base-300 text-base font-bold">yarn chain</code> ?
|
||||
</p>
|
||||
<p className="mt-1 break-normal">
|
||||
- Or you can change <code className="italic bg-base-300 text-base font-bold">targetNetwork</code> in{" "}
|
||||
<code className="italic bg-base-300 text-base font-bold">scaffold.config.ts</code>
|
||||
</p>
|
||||
</>,
|
||||
);
|
||||
}
|
||||
}, [hasError]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto my-10">
|
||||
<SearchBar />
|
||||
<TransactionsTable blocks={blocks} transactionReceipts={transactionReceipts} />
|
||||
<PaginationButton currentPage={currentPage} totalItems={Number(totalBlocks)} setCurrentPage={setCurrentPage} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BlockExplorer;
|
||||
@@ -0,0 +1,23 @@
|
||||
import TransactionComp from "../_components/TransactionComp";
|
||||
import type { NextPage } from "next";
|
||||
import { Hash } from "viem";
|
||||
import { isZeroAddress } from "~~/utils/scaffold-eth/common";
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ txHash?: Hash }>;
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
// An workaround to enable static exports in Next.js, generating single dummy page.
|
||||
return [{ txHash: "0x0000000000000000000000000000000000000000" }];
|
||||
}
|
||||
const TransactionPage: NextPage<PageProps> = async (props: PageProps) => {
|
||||
const params = await props.params;
|
||||
const txHash = params?.txHash as Hash;
|
||||
|
||||
if (isZeroAddress(txHash)) return null;
|
||||
|
||||
return <TransactionComp txHash={txHash} />;
|
||||
};
|
||||
|
||||
export default TransactionPage;
|
||||
@@ -0,0 +1,152 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Hash, Transaction, TransactionReceipt, formatEther, formatUnits } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { usePublicClient } from "wagmi";
|
||||
import { Address } from "~~/components/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { decodeTransactionData, getFunctionDetails } from "~~/utils/scaffold-eth";
|
||||
import { replacer } from "~~/utils/scaffold-eth/common";
|
||||
|
||||
const TransactionComp = ({ txHash }: { txHash: Hash }) => {
|
||||
const client = usePublicClient({ chainId: hardhat.id });
|
||||
const router = useRouter();
|
||||
const [transaction, setTransaction] = useState<Transaction>();
|
||||
const [receipt, setReceipt] = useState<TransactionReceipt>();
|
||||
const [functionCalled, setFunctionCalled] = useState<string>();
|
||||
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
|
||||
useEffect(() => {
|
||||
if (txHash && client) {
|
||||
const fetchTransaction = async () => {
|
||||
const tx = await client.getTransaction({ hash: txHash });
|
||||
const receipt = await client.getTransactionReceipt({ hash: txHash });
|
||||
|
||||
const transactionWithDecodedData = decodeTransactionData(tx);
|
||||
setTransaction(transactionWithDecodedData);
|
||||
setReceipt(receipt);
|
||||
|
||||
const functionCalled = transactionWithDecodedData.input.substring(0, 10);
|
||||
setFunctionCalled(functionCalled);
|
||||
};
|
||||
|
||||
fetchTransaction();
|
||||
}
|
||||
}, [client, txHash]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto mt-10 mb-20 px-10 md:px-0">
|
||||
<button className="btn btn-sm btn-primary" onClick={() => router.back()}>
|
||||
Back
|
||||
</button>
|
||||
{transaction ? (
|
||||
<div className="overflow-x-auto">
|
||||
<h2 className="text-3xl font-bold mb-4 text-center text-primary-content">Transaction Details</h2>{" "}
|
||||
<table className="table rounded-lg bg-base-100 w-full shadow-lg md:table-lg table-md">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Transaction Hash:</strong>
|
||||
</td>
|
||||
<td>{transaction.hash}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Block Number:</strong>
|
||||
</td>
|
||||
<td>{Number(transaction.blockNumber)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>From:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<Address address={transaction.from} format="long" onlyEnsOrAddress />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>To:</strong>
|
||||
</td>
|
||||
<td>
|
||||
{!receipt?.contractAddress ? (
|
||||
transaction.to && <Address address={transaction.to} format="long" onlyEnsOrAddress />
|
||||
) : (
|
||||
<span>
|
||||
Contract Creation:
|
||||
<Address address={receipt.contractAddress} format="long" onlyEnsOrAddress />
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Value:</strong>
|
||||
</td>
|
||||
<td>
|
||||
{formatEther(transaction.value)} {targetNetwork.nativeCurrency.symbol}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Function called:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<div className="w-full md:max-w-[600px] lg:max-w-[800px] overflow-x-auto whitespace-nowrap">
|
||||
{functionCalled === "0x" ? (
|
||||
"This transaction did not call any function."
|
||||
) : (
|
||||
<>
|
||||
<span className="mr-2">{getFunctionDetails(transaction)}</span>
|
||||
<span className="badge badge-primary font-bold">{functionCalled}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Gas Price:</strong>
|
||||
</td>
|
||||
<td>{formatUnits(transaction.gasPrice || 0n, 9)} Gwei</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Data:</strong>
|
||||
</td>
|
||||
<td className="form-control">
|
||||
<textarea
|
||||
readOnly
|
||||
value={transaction.input}
|
||||
className="p-0 w-full textarea-primary bg-inherit h-[150px]"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<strong>Logs:</strong>
|
||||
</td>
|
||||
<td>
|
||||
<ul>
|
||||
{receipt?.logs?.map((log, i) => (
|
||||
<li key={i}>
|
||||
<strong>Log {i} topics:</strong> {JSON.stringify(log.topics, replacer, 2)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-2xl text-base-content">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionComp;
|
||||
73
packages/nextjs/app/debug/_components/DebugContracts.tsx
Normal file
73
packages/nextjs/app/debug/_components/DebugContracts.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useSessionStorage } from "usehooks-ts";
|
||||
import { BarsArrowUpIcon } from "@heroicons/react/20/solid";
|
||||
import { ContractUI } from "~~/app/debug/_components/contract";
|
||||
import { ContractName, GenericContract } from "~~/utils/scaffold-eth/contract";
|
||||
import { useAllContracts } from "~~/utils/scaffold-eth/contractsData";
|
||||
|
||||
const selectedContractStorageKey = "scaffoldEth2.selectedContract";
|
||||
|
||||
export function DebugContracts() {
|
||||
const contractsData = useAllContracts();
|
||||
const contractNames = useMemo(
|
||||
() =>
|
||||
Object.keys(contractsData).sort((a, b) => {
|
||||
return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" });
|
||||
}) as ContractName[],
|
||||
[contractsData],
|
||||
);
|
||||
|
||||
const [selectedContract, setSelectedContract] = useSessionStorage<ContractName>(
|
||||
selectedContractStorageKey,
|
||||
contractNames[0],
|
||||
{ initializeWithValue: false },
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!contractNames.includes(selectedContract)) {
|
||||
setSelectedContract(contractNames[0]);
|
||||
}
|
||||
}, [contractNames, selectedContract, setSelectedContract]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-y-6 lg:gap-y-8 py-8 lg:py-12 justify-center items-center">
|
||||
{contractNames.length === 0 ? (
|
||||
<p className="text-3xl mt-14">No contracts found!</p>
|
||||
) : (
|
||||
<>
|
||||
{contractNames.length > 1 && (
|
||||
<div className="flex flex-row gap-2 w-full max-w-7xl pb-1 px-6 lg:px-10 flex-wrap">
|
||||
{contractNames.map(contractName => (
|
||||
<button
|
||||
className={`btn btn-secondary btn-sm font-light hover:border-transparent ${
|
||||
contractName === selectedContract
|
||||
? "bg-base-300 hover:bg-base-300 no-animation"
|
||||
: "bg-base-100 hover:bg-secondary"
|
||||
}`}
|
||||
key={contractName}
|
||||
onClick={() => setSelectedContract(contractName)}
|
||||
>
|
||||
{contractName}
|
||||
{(contractsData[contractName] as GenericContract)?.external && (
|
||||
<span className="tooltip tooltip-top tooltip-accent" data-tip="External contract">
|
||||
<BarsArrowUpIcon className="h-4 w-4 cursor-pointer" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{contractNames.map(contractName => (
|
||||
<ContractUI
|
||||
key={contractName}
|
||||
contractName={contractName}
|
||||
className={contractName === selectedContract ? "" : "hidden"}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { Dispatch, SetStateAction } from "react";
|
||||
import { Tuple } from "./Tuple";
|
||||
import { TupleArray } from "./TupleArray";
|
||||
import { AbiParameter } from "abitype";
|
||||
import {
|
||||
AddressInput,
|
||||
Bytes32Input,
|
||||
BytesInput,
|
||||
InputBase,
|
||||
IntegerInput,
|
||||
IntegerVariant,
|
||||
} from "~~/components/scaffold-eth";
|
||||
import { AbiParameterTuple } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
type ContractInputProps = {
|
||||
setForm: Dispatch<SetStateAction<Record<string, any>>>;
|
||||
form: Record<string, any> | undefined;
|
||||
stateObjectKey: string;
|
||||
paramType: AbiParameter;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic Input component to handle input's based on their function param type
|
||||
*/
|
||||
export const ContractInput = ({ setForm, form, stateObjectKey, paramType }: ContractInputProps) => {
|
||||
const inputProps = {
|
||||
name: stateObjectKey,
|
||||
value: form?.[stateObjectKey],
|
||||
placeholder: paramType.name ? `${paramType.type} ${paramType.name}` : paramType.type,
|
||||
onChange: (value: any) => {
|
||||
setForm(form => ({ ...form, [stateObjectKey]: value }));
|
||||
},
|
||||
};
|
||||
|
||||
const renderInput = () => {
|
||||
switch (paramType.type) {
|
||||
case "address":
|
||||
return <AddressInput {...inputProps} />;
|
||||
case "bytes32":
|
||||
return <Bytes32Input {...inputProps} />;
|
||||
case "bytes":
|
||||
return <BytesInput {...inputProps} />;
|
||||
case "string":
|
||||
return <InputBase {...inputProps} />;
|
||||
case "tuple":
|
||||
return (
|
||||
<Tuple
|
||||
setParentForm={setForm}
|
||||
parentForm={form}
|
||||
abiTupleParameter={paramType as AbiParameterTuple}
|
||||
parentStateObjectKey={stateObjectKey}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
// Handling 'int' types and 'tuple[]' types
|
||||
if (paramType.type.includes("int") && !paramType.type.includes("[")) {
|
||||
return <IntegerInput {...inputProps} variant={paramType.type as IntegerVariant} />;
|
||||
} else if (paramType.type.startsWith("tuple[")) {
|
||||
return (
|
||||
<TupleArray
|
||||
setParentForm={setForm}
|
||||
parentForm={form}
|
||||
abiTupleParameter={paramType as AbiParameterTuple}
|
||||
parentStateObjectKey={stateObjectKey}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return <InputBase {...inputProps} />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5 w-full">
|
||||
<div className="flex items-center ml-2">
|
||||
{paramType.name && <span className="text-xs font-medium mr-2 leading-none">{paramType.name}</span>}
|
||||
<span className="block text-xs font-extralight leading-none">{paramType.type}</span>
|
||||
</div>
|
||||
{renderInput()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Abi, AbiFunction } from "abitype";
|
||||
import { ReadOnlyFunctionForm } from "~~/app/debug/_components/contract";
|
||||
import { Contract, ContractName, GenericContract, InheritedFunctions } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
export const ContractReadMethods = ({ deployedContractData }: { deployedContractData: Contract<ContractName> }) => {
|
||||
if (!deployedContractData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const functionsToDisplay = (
|
||||
((deployedContractData.abi || []) as Abi).filter(part => part.type === "function") as AbiFunction[]
|
||||
)
|
||||
.filter(fn => {
|
||||
const isQueryableWithParams =
|
||||
(fn.stateMutability === "view" || fn.stateMutability === "pure") && fn.inputs.length > 0;
|
||||
return isQueryableWithParams;
|
||||
})
|
||||
.map(fn => {
|
||||
return {
|
||||
fn,
|
||||
inheritedFrom: ((deployedContractData as GenericContract)?.inheritedFunctions as InheritedFunctions)?.[fn.name],
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (b.inheritedFrom ? b.inheritedFrom.localeCompare(a.inheritedFrom) : 1));
|
||||
|
||||
if (!functionsToDisplay.length) {
|
||||
return <>No read methods</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{functionsToDisplay.map(({ fn, inheritedFrom }) => (
|
||||
<ReadOnlyFunctionForm
|
||||
abi={deployedContractData.abi as Abi}
|
||||
contractAddress={deployedContractData.address}
|
||||
abiFunction={fn}
|
||||
key={fn.name}
|
||||
inheritedFrom={inheritedFrom}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
104
packages/nextjs/app/debug/_components/contract/ContractUI.tsx
Normal file
104
packages/nextjs/app/debug/_components/contract/ContractUI.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
// @refresh reset
|
||||
import { useReducer } from "react";
|
||||
import { ContractReadMethods } from "./ContractReadMethods";
|
||||
import { ContractVariables } from "./ContractVariables";
|
||||
import { ContractWriteMethods } from "./ContractWriteMethods";
|
||||
import { Address, Balance } from "~~/components/scaffold-eth";
|
||||
import { useDeployedContractInfo, useNetworkColor } from "~~/hooks/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { ContractName } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
type ContractUIProps = {
|
||||
contractName: ContractName;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* UI component to interface with deployed contracts.
|
||||
**/
|
||||
export const ContractUI = ({ contractName, className = "" }: ContractUIProps) => {
|
||||
const [refreshDisplayVariables, triggerRefreshDisplayVariables] = useReducer(value => !value, false);
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const { data: deployedContractData, isLoading: deployedContractLoading } = useDeployedContractInfo({ contractName });
|
||||
const networkColor = useNetworkColor();
|
||||
|
||||
if (deployedContractLoading) {
|
||||
return (
|
||||
<div className="mt-14">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!deployedContractData) {
|
||||
return (
|
||||
<p className="text-3xl mt-14">
|
||||
{`No contract found by the name of "${contractName}" on chain "${targetNetwork.name}"!`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`grid grid-cols-1 lg:grid-cols-6 px-6 lg:px-10 lg:gap-12 w-full max-w-7xl my-0 ${className}`}>
|
||||
<div className="col-span-5 grid grid-cols-1 lg:grid-cols-3 gap-8 lg:gap-10">
|
||||
<div className="col-span-1 flex flex-col">
|
||||
<div className="bg-base-100 border-base-300 border shadow-md shadow-secondary rounded-3xl px-6 lg:px-8 mb-6 space-y-1 py-4">
|
||||
<div className="flex">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-bold">{contractName}</span>
|
||||
<Address address={deployedContractData.address} onlyEnsOrAddress />
|
||||
<div className="flex gap-1 items-center">
|
||||
<span className="font-bold text-sm">Balance:</span>
|
||||
<Balance address={deployedContractData.address} className="px-0 h-1.5 min-h-[0.375rem]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{targetNetwork && (
|
||||
<p className="my-0 text-sm">
|
||||
<span className="font-bold">Network</span>:{" "}
|
||||
<span style={{ color: networkColor }}>{targetNetwork.name}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-base-300 rounded-3xl px-6 lg:px-8 py-4 shadow-lg shadow-base-300">
|
||||
<ContractVariables
|
||||
refreshDisplayVariables={refreshDisplayVariables}
|
||||
deployedContractData={deployedContractData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-1 lg:col-span-2 flex flex-col gap-6">
|
||||
<div className="z-10">
|
||||
<div className="bg-base-100 rounded-3xl shadow-md shadow-secondary border border-base-300 flex flex-col mt-10 relative">
|
||||
<div className="h-[5rem] w-[5.5rem] bg-base-300 absolute self-start rounded-[22px] -top-[38px] -left-[1px] -z-10 py-[0.65rem] shadow-lg shadow-base-300">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<p className="my-0 text-sm">Read</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 divide-y divide-base-300">
|
||||
<ContractReadMethods deployedContractData={deployedContractData} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="z-10">
|
||||
<div className="bg-base-100 rounded-3xl shadow-md shadow-secondary border border-base-300 flex flex-col mt-10 relative">
|
||||
<div className="h-[5rem] w-[5.5rem] bg-base-300 absolute self-start rounded-[22px] -top-[38px] -left-[1px] -z-10 py-[0.65rem] shadow-lg shadow-base-300">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<p className="my-0 text-sm">Write</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 divide-y divide-base-300">
|
||||
<ContractWriteMethods
|
||||
deployedContractData={deployedContractData}
|
||||
onChange={triggerRefreshDisplayVariables}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { DisplayVariable } from "./DisplayVariable";
|
||||
import { Abi, AbiFunction } from "abitype";
|
||||
import { Contract, ContractName, GenericContract, InheritedFunctions } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
export const ContractVariables = ({
|
||||
refreshDisplayVariables,
|
||||
deployedContractData,
|
||||
}: {
|
||||
refreshDisplayVariables: boolean;
|
||||
deployedContractData: Contract<ContractName>;
|
||||
}) => {
|
||||
if (!deployedContractData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const functionsToDisplay = (
|
||||
(deployedContractData.abi as Abi).filter(part => part.type === "function") as AbiFunction[]
|
||||
)
|
||||
.filter(fn => {
|
||||
const isQueryableWithNoParams =
|
||||
(fn.stateMutability === "view" || fn.stateMutability === "pure") && fn.inputs.length === 0;
|
||||
return isQueryableWithNoParams;
|
||||
})
|
||||
.map(fn => {
|
||||
return {
|
||||
fn,
|
||||
inheritedFrom: ((deployedContractData as GenericContract)?.inheritedFunctions as InheritedFunctions)?.[fn.name],
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (b.inheritedFrom ? b.inheritedFrom.localeCompare(a.inheritedFrom) : 1));
|
||||
|
||||
if (!functionsToDisplay.length) {
|
||||
return <>No contract variables</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{functionsToDisplay.map(({ fn, inheritedFrom }) => (
|
||||
<DisplayVariable
|
||||
abi={deployedContractData.abi as Abi}
|
||||
abiFunction={fn}
|
||||
contractAddress={deployedContractData.address}
|
||||
key={fn.name}
|
||||
refreshDisplayVariables={refreshDisplayVariables}
|
||||
inheritedFrom={inheritedFrom}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Abi, AbiFunction } from "abitype";
|
||||
import { WriteOnlyFunctionForm } from "~~/app/debug/_components/contract";
|
||||
import { Contract, ContractName, GenericContract, InheritedFunctions } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
export const ContractWriteMethods = ({
|
||||
onChange,
|
||||
deployedContractData,
|
||||
}: {
|
||||
onChange: () => void;
|
||||
deployedContractData: Contract<ContractName>;
|
||||
}) => {
|
||||
if (!deployedContractData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const functionsToDisplay = (
|
||||
(deployedContractData.abi as Abi).filter(part => part.type === "function") as AbiFunction[]
|
||||
)
|
||||
.filter(fn => {
|
||||
const isWriteableFunction = fn.stateMutability !== "view" && fn.stateMutability !== "pure";
|
||||
return isWriteableFunction;
|
||||
})
|
||||
.map(fn => {
|
||||
return {
|
||||
fn,
|
||||
inheritedFrom: ((deployedContractData as GenericContract)?.inheritedFunctions as InheritedFunctions)?.[fn.name],
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (b.inheritedFrom ? b.inheritedFrom.localeCompare(a.inheritedFrom) : 1));
|
||||
|
||||
if (!functionsToDisplay.length) {
|
||||
return <>No write methods</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{functionsToDisplay.map(({ fn, inheritedFrom }, idx) => (
|
||||
<WriteOnlyFunctionForm
|
||||
abi={deployedContractData.abi as Abi}
|
||||
key={`${fn.name}-${idx}}`}
|
||||
abiFunction={fn}
|
||||
onChange={onChange}
|
||||
contractAddress={deployedContractData.address}
|
||||
inheritedFrom={inheritedFrom}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { InheritanceTooltip } from "./InheritanceTooltip";
|
||||
import { displayTxResult } from "./utilsDisplay";
|
||||
import { Abi, AbiFunction } from "abitype";
|
||||
import { Address } from "viem";
|
||||
import { useReadContract } from "wagmi";
|
||||
import { ArrowPathIcon } from "@heroicons/react/24/outline";
|
||||
import { useAnimationConfig } from "~~/hooks/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { getParsedError, notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
type DisplayVariableProps = {
|
||||
contractAddress: Address;
|
||||
abiFunction: AbiFunction;
|
||||
refreshDisplayVariables: boolean;
|
||||
inheritedFrom?: string;
|
||||
abi: Abi;
|
||||
};
|
||||
|
||||
export const DisplayVariable = ({
|
||||
contractAddress,
|
||||
abiFunction,
|
||||
refreshDisplayVariables,
|
||||
abi,
|
||||
inheritedFrom,
|
||||
}: DisplayVariableProps) => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
|
||||
const {
|
||||
data: result,
|
||||
isFetching,
|
||||
refetch,
|
||||
error,
|
||||
} = useReadContract({
|
||||
address: contractAddress,
|
||||
functionName: abiFunction.name,
|
||||
abi: abi,
|
||||
chainId: targetNetwork.id,
|
||||
query: {
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { showAnimation } = useAnimationConfig(result);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [refetch, refreshDisplayVariables]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const parsedError = getParsedError(error);
|
||||
notification.error(parsedError);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 pb-2">
|
||||
<div className="flex items-center">
|
||||
<h3 className="font-medium text-lg mb-0 break-all">{abiFunction.name}</h3>
|
||||
<button className="btn btn-ghost btn-xs" onClick={async () => await refetch()}>
|
||||
{isFetching ? (
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
) : (
|
||||
<ArrowPathIcon className="h-3 w-3 cursor-pointer" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<InheritanceTooltip inheritedFrom={inheritedFrom} />
|
||||
</div>
|
||||
<div className="text-base-content/80 flex flex-col items-start">
|
||||
<div>
|
||||
<div
|
||||
className={`break-all block transition bg-transparent ${
|
||||
showAnimation ? "bg-warning rounded-xs animate-pulse-fast" : ""
|
||||
}`}
|
||||
>
|
||||
{displayTxResult(result)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { InformationCircleIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export const InheritanceTooltip = ({ inheritedFrom }: { inheritedFrom?: string }) => (
|
||||
<>
|
||||
{inheritedFrom && (
|
||||
<span
|
||||
className="tooltip tooltip-top tooltip-accent px-2 md:break-words"
|
||||
data-tip={`Inherited from: ${inheritedFrom}`}
|
||||
>
|
||||
<InformationCircleIcon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { InheritanceTooltip } from "./InheritanceTooltip";
|
||||
import { Abi, AbiFunction } from "abitype";
|
||||
import { Address } from "viem";
|
||||
import { useReadContract } from "wagmi";
|
||||
import {
|
||||
ContractInput,
|
||||
displayTxResult,
|
||||
getFunctionInputKey,
|
||||
getInitialFormState,
|
||||
getParsedContractFunctionArgs,
|
||||
transformAbiFunction,
|
||||
} from "~~/app/debug/_components/contract";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { getParsedError, notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
type ReadOnlyFunctionFormProps = {
|
||||
contractAddress: Address;
|
||||
abiFunction: AbiFunction;
|
||||
inheritedFrom?: string;
|
||||
abi: Abi;
|
||||
};
|
||||
|
||||
export const ReadOnlyFunctionForm = ({
|
||||
contractAddress,
|
||||
abiFunction,
|
||||
inheritedFrom,
|
||||
abi,
|
||||
}: ReadOnlyFunctionFormProps) => {
|
||||
const [form, setForm] = useState<Record<string, any>>(() => getInitialFormState(abiFunction));
|
||||
const [result, setResult] = useState<unknown>();
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
|
||||
const { isFetching, refetch, error } = useReadContract({
|
||||
address: contractAddress,
|
||||
functionName: abiFunction.name,
|
||||
abi: abi,
|
||||
args: getParsedContractFunctionArgs(form),
|
||||
chainId: targetNetwork.id,
|
||||
query: {
|
||||
enabled: false,
|
||||
retry: false,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const parsedError = getParsedError(error);
|
||||
notification.error(parsedError);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const transformedFunction = useMemo(() => transformAbiFunction(abiFunction), [abiFunction]);
|
||||
const inputElements = transformedFunction.inputs.map((input, inputIndex) => {
|
||||
const key = getFunctionInputKey(abiFunction.name, input, inputIndex);
|
||||
return (
|
||||
<ContractInput
|
||||
key={key}
|
||||
setForm={updatedFormValue => {
|
||||
setResult(undefined);
|
||||
setForm(updatedFormValue);
|
||||
}}
|
||||
form={form}
|
||||
stateObjectKey={key}
|
||||
paramType={input}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 py-5 first:pt-0 last:pb-1">
|
||||
<p className="font-medium my-0 break-words">
|
||||
{abiFunction.name}
|
||||
<InheritanceTooltip inheritedFrom={inheritedFrom} />
|
||||
</p>
|
||||
{inputElements}
|
||||
<div className="flex flex-col md:flex-row justify-between gap-2 flex-wrap">
|
||||
<div className="grow w-full md:max-w-[80%]">
|
||||
{result !== null && result !== undefined && (
|
||||
<div className="bg-secondary rounded-3xl text-sm px-4 py-1.5 break-words overflow-auto">
|
||||
<p className="font-bold m-0 mb-1">Result:</p>
|
||||
<pre className="whitespace-pre-wrap break-words">{displayTxResult(result, "sm")}</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-secondary btn-sm self-end md:self-start"
|
||||
onClick={async () => {
|
||||
const { data } = await refetch();
|
||||
setResult(data);
|
||||
}}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching && <span className="loading loading-spinner loading-xs"></span>}
|
||||
Read 📡
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
44
packages/nextjs/app/debug/_components/contract/Tuple.tsx
Normal file
44
packages/nextjs/app/debug/_components/contract/Tuple.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { ContractInput } from "./ContractInput";
|
||||
import { getFunctionInputKey, getInitialTupleFormState } from "./utilsContract";
|
||||
import { replacer } from "~~/utils/scaffold-eth/common";
|
||||
import { AbiParameterTuple } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
type TupleProps = {
|
||||
abiTupleParameter: AbiParameterTuple;
|
||||
setParentForm: Dispatch<SetStateAction<Record<string, any>>>;
|
||||
parentStateObjectKey: string;
|
||||
parentForm: Record<string, any> | undefined;
|
||||
};
|
||||
|
||||
export const Tuple = ({ abiTupleParameter, setParentForm, parentStateObjectKey }: TupleProps) => {
|
||||
const [form, setForm] = useState<Record<string, any>>(() => getInitialTupleFormState(abiTupleParameter));
|
||||
|
||||
useEffect(() => {
|
||||
const values = Object.values(form);
|
||||
const argsStruct: Record<string, any> = {};
|
||||
abiTupleParameter.components.forEach((component, componentIndex) => {
|
||||
argsStruct[component.name || `input_${componentIndex}_`] = values[componentIndex];
|
||||
});
|
||||
|
||||
setParentForm(parentForm => ({ ...parentForm, [parentStateObjectKey]: JSON.stringify(argsStruct, replacer) }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(form, replacer)]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div tabIndex={0} className="collapse collapse-arrow bg-base-200 pl-4 py-1.5 border-2 border-secondary">
|
||||
<input type="checkbox" className="min-h-fit! peer" />
|
||||
<div className="collapse-title after:top-3.5! p-0 min-h-fit! peer-checked:mb-2 text-primary-content/50">
|
||||
<p className="m-0 p-0 text-[1rem]">{abiTupleParameter.internalType}</p>
|
||||
</div>
|
||||
<div className="ml-3 flex-col space-y-4 border-secondary/80 border-l-2 pl-4 collapse-content">
|
||||
{abiTupleParameter?.components?.map((param, index) => {
|
||||
const key = getFunctionInputKey(abiTupleParameter.name || "tuple", param, index);
|
||||
return <ContractInput setForm={setForm} form={form} key={key} stateObjectKey={key} paramType={param} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
142
packages/nextjs/app/debug/_components/contract/TupleArray.tsx
Normal file
142
packages/nextjs/app/debug/_components/contract/TupleArray.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { Dispatch, SetStateAction, useEffect, useState } from "react";
|
||||
import { ContractInput } from "./ContractInput";
|
||||
import { getFunctionInputKey, getInitialTupleArrayFormState } from "./utilsContract";
|
||||
import { replacer } from "~~/utils/scaffold-eth/common";
|
||||
import { AbiParameterTuple } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
type TupleArrayProps = {
|
||||
abiTupleParameter: AbiParameterTuple & { isVirtual?: true };
|
||||
setParentForm: Dispatch<SetStateAction<Record<string, any>>>;
|
||||
parentStateObjectKey: string;
|
||||
parentForm: Record<string, any> | undefined;
|
||||
};
|
||||
|
||||
export const TupleArray = ({ abiTupleParameter, setParentForm, parentStateObjectKey }: TupleArrayProps) => {
|
||||
const [form, setForm] = useState<Record<string, any>>(() => getInitialTupleArrayFormState(abiTupleParameter));
|
||||
const [additionalInputs, setAdditionalInputs] = useState<Array<typeof abiTupleParameter.components>>([
|
||||
abiTupleParameter.components,
|
||||
]);
|
||||
|
||||
const depth = (abiTupleParameter.type.match(/\[\]/g) || []).length;
|
||||
|
||||
useEffect(() => {
|
||||
// Extract and group fields based on index prefix
|
||||
const groupedFields = Object.keys(form).reduce(
|
||||
(acc, key) => {
|
||||
const [indexPrefix, ...restArray] = key.split("_");
|
||||
const componentName = restArray.join("_");
|
||||
if (!acc[indexPrefix]) {
|
||||
acc[indexPrefix] = {};
|
||||
}
|
||||
acc[indexPrefix][componentName] = form[key];
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Record<string, any>>,
|
||||
);
|
||||
|
||||
let argsArray: Array<Record<string, any>> = [];
|
||||
|
||||
Object.keys(groupedFields).forEach(key => {
|
||||
const currentKeyValues = Object.values(groupedFields[key]);
|
||||
|
||||
const argsStruct: Record<string, any> = {};
|
||||
abiTupleParameter.components.forEach((component, componentIndex) => {
|
||||
argsStruct[component.name || `input_${componentIndex}_`] = currentKeyValues[componentIndex];
|
||||
});
|
||||
|
||||
argsArray.push(argsStruct);
|
||||
});
|
||||
|
||||
if (depth > 1) {
|
||||
argsArray = argsArray.map(args => {
|
||||
return args[abiTupleParameter.components[0].name || "tuple"];
|
||||
});
|
||||
}
|
||||
|
||||
setParentForm(parentForm => {
|
||||
return { ...parentForm, [parentStateObjectKey]: JSON.stringify(argsArray, replacer) };
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(form, replacer)]);
|
||||
|
||||
const addInput = () => {
|
||||
setAdditionalInputs(previousValue => {
|
||||
const newAdditionalInputs = [...previousValue, abiTupleParameter.components];
|
||||
|
||||
// Add the new inputs to the form
|
||||
setForm(form => {
|
||||
const newForm = { ...form };
|
||||
abiTupleParameter.components.forEach((component, componentIndex) => {
|
||||
const key = getFunctionInputKey(
|
||||
`${newAdditionalInputs.length - 1}_${abiTupleParameter.name || "tuple"}`,
|
||||
component,
|
||||
componentIndex,
|
||||
);
|
||||
newForm[key] = "";
|
||||
});
|
||||
return newForm;
|
||||
});
|
||||
|
||||
return newAdditionalInputs;
|
||||
});
|
||||
};
|
||||
|
||||
const removeInput = () => {
|
||||
// Remove the last inputs from the form
|
||||
setForm(form => {
|
||||
const newForm = { ...form };
|
||||
abiTupleParameter.components.forEach((component, componentIndex) => {
|
||||
const key = getFunctionInputKey(
|
||||
`${additionalInputs.length - 1}_${abiTupleParameter.name || "tuple"}`,
|
||||
component,
|
||||
componentIndex,
|
||||
);
|
||||
delete newForm[key];
|
||||
});
|
||||
return newForm;
|
||||
});
|
||||
setAdditionalInputs(inputs => inputs.slice(0, -1));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="collapse collapse-arrow bg-base-200 pl-4 py-1.5 border-2 border-secondary">
|
||||
<input type="checkbox" className="min-h-fit! peer" />
|
||||
<div className="collapse-title after:top-3.5! p-0 min-h-fit! peer-checked:mb-1 text-primary-content/50">
|
||||
<p className="m-0 text-[1rem]">{abiTupleParameter.internalType}</p>
|
||||
</div>
|
||||
<div className="ml-3 flex-col space-y-2 border-secondary/70 border-l-2 pl-4 collapse-content">
|
||||
{additionalInputs.map((additionalInput, additionalIndex) => (
|
||||
<div key={additionalIndex} className="space-y-1">
|
||||
<span className="badge bg-base-300 badge-sm">
|
||||
{depth > 1 ? `${additionalIndex}` : `tuple[${additionalIndex}]`}
|
||||
</span>
|
||||
<div className="space-y-4">
|
||||
{additionalInput.map((param, index) => {
|
||||
const key = getFunctionInputKey(
|
||||
`${additionalIndex}_${abiTupleParameter.name || "tuple"}`,
|
||||
param,
|
||||
index,
|
||||
);
|
||||
return (
|
||||
<ContractInput setForm={setForm} form={form} key={key} stateObjectKey={key} paramType={param} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex space-x-2">
|
||||
<button className="btn btn-sm btn-secondary" onClick={addInput}>
|
||||
+
|
||||
</button>
|
||||
{additionalInputs.length > 0 && (
|
||||
<button className="btn btn-sm btn-secondary" onClick={removeInput}>
|
||||
-
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
packages/nextjs/app/debug/_components/contract/TxReceipt.tsx
Normal file
42
packages/nextjs/app/debug/_components/contract/TxReceipt.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { TransactionReceipt } from "viem";
|
||||
import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
|
||||
import { ObjectFieldDisplay } from "~~/app/debug/_components/contract";
|
||||
import { useCopyToClipboard } from "~~/hooks/scaffold-eth/useCopyToClipboard";
|
||||
import { replacer } from "~~/utils/scaffold-eth/common";
|
||||
|
||||
export const TxReceipt = ({ txResult }: { txResult: TransactionReceipt }) => {
|
||||
const { copyToClipboard: copyTxResultToClipboard, isCopiedToClipboard: isTxResultCopiedToClipboard } =
|
||||
useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<div className="flex text-sm rounded-3xl peer-checked:rounded-b-none min-h-0 bg-secondary py-0">
|
||||
<div className="mt-1 pl-2">
|
||||
{isTxResultCopiedToClipboard ? (
|
||||
<CheckCircleIcon
|
||||
className="ml-1.5 text-xl font-normal text-base-content h-5 w-5 cursor-pointer"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
) : (
|
||||
<DocumentDuplicateIcon
|
||||
className="ml-1.5 text-xl font-normal h-5 w-5 cursor-pointer"
|
||||
aria-hidden="true"
|
||||
onClick={() => copyTxResultToClipboard(JSON.stringify(txResult, replacer, 2))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div tabIndex={0} className="flex-wrap collapse collapse-arrow">
|
||||
<input type="checkbox" className="min-h-0! peer" />
|
||||
<div className="collapse-title text-sm min-h-0! py-1.5 pl-1 after:top-4!">
|
||||
<strong>Transaction Receipt</strong>
|
||||
</div>
|
||||
<div className="collapse-content overflow-auto bg-secondary rounded-t-none rounded-3xl pl-0!">
|
||||
<pre className="text-xs">
|
||||
{Object.entries(txResult).map(([k, v]) => (
|
||||
<ObjectFieldDisplay name={k} value={v} size="xs" leftPad={false} key={k} />
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { InheritanceTooltip } from "./InheritanceTooltip";
|
||||
import { Abi, AbiFunction } from "abitype";
|
||||
import { Address, TransactionReceipt } from "viem";
|
||||
import { useAccount, useConfig, useWaitForTransactionReceipt, useWriteContract } from "wagmi";
|
||||
import {
|
||||
ContractInput,
|
||||
TxReceipt,
|
||||
getFunctionInputKey,
|
||||
getInitialFormState,
|
||||
getParsedContractFunctionArgs,
|
||||
transformAbiFunction,
|
||||
} from "~~/app/debug/_components/contract";
|
||||
import { IntegerInput } from "~~/components/scaffold-eth";
|
||||
import { useTransactor } from "~~/hooks/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { AllowedChainIds } from "~~/utils/scaffold-eth";
|
||||
import { simulateContractWriteAndNotifyError } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
type WriteOnlyFunctionFormProps = {
|
||||
abi: Abi;
|
||||
abiFunction: AbiFunction;
|
||||
onChange: () => void;
|
||||
contractAddress: Address;
|
||||
inheritedFrom?: string;
|
||||
};
|
||||
|
||||
export const WriteOnlyFunctionForm = ({
|
||||
abi,
|
||||
abiFunction,
|
||||
onChange,
|
||||
contractAddress,
|
||||
inheritedFrom,
|
||||
}: WriteOnlyFunctionFormProps) => {
|
||||
const [form, setForm] = useState<Record<string, any>>(() => getInitialFormState(abiFunction));
|
||||
const [txValue, setTxValue] = useState<string>("");
|
||||
const { chain } = useAccount();
|
||||
const writeTxn = useTransactor();
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const writeDisabled = !chain || chain?.id !== targetNetwork.id;
|
||||
|
||||
const { data: result, isPending, writeContractAsync } = useWriteContract();
|
||||
|
||||
const wagmiConfig = useConfig();
|
||||
|
||||
const handleWrite = async () => {
|
||||
if (writeContractAsync) {
|
||||
try {
|
||||
const writeContractObj = {
|
||||
address: contractAddress,
|
||||
functionName: abiFunction.name,
|
||||
abi: abi,
|
||||
args: getParsedContractFunctionArgs(form),
|
||||
value: BigInt(txValue),
|
||||
};
|
||||
await simulateContractWriteAndNotifyError({
|
||||
wagmiConfig,
|
||||
writeContractParams: writeContractObj,
|
||||
chainId: targetNetwork.id as AllowedChainIds,
|
||||
});
|
||||
|
||||
const makeWriteWithParams = () => writeContractAsync(writeContractObj);
|
||||
await writeTxn(makeWriteWithParams);
|
||||
onChange();
|
||||
} catch (e: any) {
|
||||
console.error("⚡️ ~ file: WriteOnlyFunctionForm.tsx:handleWrite ~ error", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const [displayedTxResult, setDisplayedTxResult] = useState<TransactionReceipt>();
|
||||
const { data: txResult } = useWaitForTransactionReceipt({
|
||||
hash: result,
|
||||
});
|
||||
useEffect(() => {
|
||||
setDisplayedTxResult(txResult);
|
||||
}, [txResult]);
|
||||
|
||||
const transformedFunction = useMemo(() => transformAbiFunction(abiFunction), [abiFunction]);
|
||||
const inputs = transformedFunction.inputs.map((input, inputIndex) => {
|
||||
const key = getFunctionInputKey(abiFunction.name, input, inputIndex);
|
||||
return (
|
||||
<ContractInput
|
||||
key={key}
|
||||
setForm={updatedFormValue => {
|
||||
setDisplayedTxResult(undefined);
|
||||
setForm(updatedFormValue);
|
||||
}}
|
||||
form={form}
|
||||
stateObjectKey={key}
|
||||
paramType={input}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const zeroInputs = inputs.length === 0 && abiFunction.stateMutability !== "payable";
|
||||
|
||||
return (
|
||||
<div className="py-5 space-y-3 first:pt-0 last:pb-1">
|
||||
<div className={`flex gap-3 ${zeroInputs ? "flex-row justify-between items-center" : "flex-col"}`}>
|
||||
<p className="font-medium my-0 break-words">
|
||||
{abiFunction.name}
|
||||
<InheritanceTooltip inheritedFrom={inheritedFrom} />
|
||||
</p>
|
||||
{inputs}
|
||||
{abiFunction.stateMutability === "payable" ? (
|
||||
<div className="flex flex-col gap-1.5 w-full">
|
||||
<div className="flex items-center ml-2">
|
||||
<span className="text-xs font-medium mr-2 leading-none">payable value</span>
|
||||
<span className="block text-xs font-extralight leading-none">wei</span>
|
||||
</div>
|
||||
<IntegerInput
|
||||
value={txValue}
|
||||
onChange={updatedTxValue => {
|
||||
setDisplayedTxResult(undefined);
|
||||
setTxValue(updatedTxValue);
|
||||
}}
|
||||
placeholder="value (wei)"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-between gap-2">
|
||||
{!zeroInputs && (
|
||||
<div className="grow basis-0">{displayedTxResult ? <TxReceipt txResult={displayedTxResult} /> : null}</div>
|
||||
)}
|
||||
<div
|
||||
className={`flex ${
|
||||
writeDisabled &&
|
||||
"tooltip tooltip-bottom tooltip-secondary before:content-[attr(data-tip)] before:-translate-x-1/3 before:left-auto before:transform-none"
|
||||
}`}
|
||||
data-tip={`${writeDisabled && "Wallet not connected or in the wrong network"}`}
|
||||
>
|
||||
<button className="btn btn-secondary btn-sm" disabled={writeDisabled || isPending} onClick={handleWrite}>
|
||||
{isPending && <span className="loading loading-spinner loading-xs"></span>}
|
||||
Send 💸
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{zeroInputs && txResult ? (
|
||||
<div className="grow basis-0">
|
||||
<TxReceipt txResult={txResult} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
8
packages/nextjs/app/debug/_components/contract/index.tsx
Normal file
8
packages/nextjs/app/debug/_components/contract/index.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from "./ContractInput";
|
||||
export * from "./ContractUI";
|
||||
export * from "./DisplayVariable";
|
||||
export * from "./ReadOnlyFunctionForm";
|
||||
export * from "./TxReceipt";
|
||||
export * from "./utilsContract";
|
||||
export * from "./utilsDisplay";
|
||||
export * from "./WriteOnlyFunctionForm";
|
||||
166
packages/nextjs/app/debug/_components/contract/utilsContract.tsx
Normal file
166
packages/nextjs/app/debug/_components/contract/utilsContract.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { AbiFunction, AbiParameter } from "abitype";
|
||||
import { AbiParameterTuple } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
/**
|
||||
* Generates a key based on function metadata
|
||||
*/
|
||||
const getFunctionInputKey = (functionName: string, input: AbiParameter, inputIndex: number): string => {
|
||||
const name = input?.name || `input_${inputIndex}_`;
|
||||
return functionName + "_" + name + "_" + input.internalType + "_" + input.type;
|
||||
};
|
||||
|
||||
const isJsonString = (str: string) => {
|
||||
try {
|
||||
JSON.parse(str);
|
||||
return true;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const isBigInt = (str: string) => {
|
||||
if (str.trim().length === 0 || str.startsWith("0")) return false;
|
||||
try {
|
||||
BigInt(str);
|
||||
return true;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Recursive function to deeply parse JSON strings, correctly handling nested arrays and encoded JSON strings
|
||||
const deepParseValues = (value: any): any => {
|
||||
if (typeof value === "string") {
|
||||
// first try with bigInt because we losse precision with JSON.parse
|
||||
if (isBigInt(value)) {
|
||||
return BigInt(value);
|
||||
}
|
||||
|
||||
if (isJsonString(value)) {
|
||||
const parsed = JSON.parse(value);
|
||||
return deepParseValues(parsed);
|
||||
}
|
||||
|
||||
// It's a string but not a JSON string, return as is
|
||||
return value;
|
||||
} else if (Array.isArray(value)) {
|
||||
// If it's an array, recursively parse each element
|
||||
return value.map(element => deepParseValues(element));
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
// If it's an object, recursively parse each value
|
||||
return Object.entries(value).reduce((acc: any, [key, val]) => {
|
||||
acc[key] = deepParseValues(val);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
// Handle boolean values represented as strings
|
||||
if (value === "true" || value === "1" || value === "0x1" || value === "0x01" || value === "0x0001") {
|
||||
return true;
|
||||
} else if (value === "false" || value === "0" || value === "0x0" || value === "0x00" || value === "0x0000") {
|
||||
return false;
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
|
||||
/**
|
||||
* parses form input with array support
|
||||
*/
|
||||
const getParsedContractFunctionArgs = (form: Record<string, any>) => {
|
||||
return Object.keys(form).map(key => {
|
||||
const valueOfArg = form[key];
|
||||
|
||||
// Attempt to deeply parse JSON strings
|
||||
return deepParseValues(valueOfArg);
|
||||
});
|
||||
};
|
||||
|
||||
const getInitialFormState = (abiFunction: AbiFunction) => {
|
||||
const initialForm: Record<string, any> = {};
|
||||
if (!abiFunction.inputs) return initialForm;
|
||||
abiFunction.inputs.forEach((input, inputIndex) => {
|
||||
const key = getFunctionInputKey(abiFunction.name, input, inputIndex);
|
||||
initialForm[key] = "";
|
||||
});
|
||||
return initialForm;
|
||||
};
|
||||
|
||||
const getInitialTupleFormState = (abiTupleParameter: AbiParameterTuple) => {
|
||||
const initialForm: Record<string, any> = {};
|
||||
if (abiTupleParameter.components.length === 0) return initialForm;
|
||||
|
||||
abiTupleParameter.components.forEach((component, componentIndex) => {
|
||||
const key = getFunctionInputKey(abiTupleParameter.name || "tuple", component, componentIndex);
|
||||
initialForm[key] = "";
|
||||
});
|
||||
return initialForm;
|
||||
};
|
||||
|
||||
const getInitialTupleArrayFormState = (abiTupleParameter: AbiParameterTuple) => {
|
||||
const initialForm: Record<string, any> = {};
|
||||
if (abiTupleParameter.components.length === 0) return initialForm;
|
||||
abiTupleParameter.components.forEach((component, componentIndex) => {
|
||||
const key = getFunctionInputKey("0_" + abiTupleParameter.name || "tuple", component, componentIndex);
|
||||
initialForm[key] = "";
|
||||
});
|
||||
return initialForm;
|
||||
};
|
||||
|
||||
const adjustInput = (input: AbiParameterTuple): AbiParameter => {
|
||||
if (input.type.startsWith("tuple[")) {
|
||||
const depth = (input.type.match(/\[\]/g) || []).length;
|
||||
return {
|
||||
...input,
|
||||
components: transformComponents(input.components, depth, {
|
||||
internalType: input.internalType || "struct",
|
||||
name: input.name,
|
||||
}),
|
||||
};
|
||||
} else if (input.components) {
|
||||
return {
|
||||
...input,
|
||||
components: input.components.map(value => adjustInput(value as AbiParameterTuple)),
|
||||
};
|
||||
}
|
||||
return input;
|
||||
};
|
||||
|
||||
const transformComponents = (
|
||||
components: readonly AbiParameter[],
|
||||
depth: number,
|
||||
parentComponentData: { internalType?: string; name?: string },
|
||||
): AbiParameter[] => {
|
||||
// Base case: if depth is 1 or no components, return the original components
|
||||
if (depth === 1 || !components) {
|
||||
return [...components];
|
||||
}
|
||||
|
||||
// Recursive case: wrap components in an additional tuple layer
|
||||
const wrappedComponents: AbiParameter = {
|
||||
internalType: `${parentComponentData.internalType || "struct"}`.replace(/\[\]/g, "") + "[]".repeat(depth - 1),
|
||||
name: `${parentComponentData.name || "tuple"}`,
|
||||
type: `tuple${"[]".repeat(depth - 1)}`,
|
||||
components: transformComponents(components, depth - 1, parentComponentData),
|
||||
};
|
||||
|
||||
return [wrappedComponents];
|
||||
};
|
||||
|
||||
const transformAbiFunction = (abiFunction: AbiFunction): AbiFunction => {
|
||||
return {
|
||||
...abiFunction,
|
||||
inputs: abiFunction.inputs.map(value => adjustInput(value as AbiParameterTuple)),
|
||||
};
|
||||
};
|
||||
|
||||
export {
|
||||
getFunctionInputKey,
|
||||
getInitialFormState,
|
||||
getParsedContractFunctionArgs,
|
||||
getInitialTupleFormState,
|
||||
getInitialTupleArrayFormState,
|
||||
transformAbiFunction,
|
||||
};
|
||||
114
packages/nextjs/app/debug/_components/contract/utilsDisplay.tsx
Normal file
114
packages/nextjs/app/debug/_components/contract/utilsDisplay.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { ReactElement, useState } from "react";
|
||||
import { TransactionBase, TransactionReceipt, formatEther, isAddress, isHex } from "viem";
|
||||
import { ArrowsRightLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { Address } from "~~/components/scaffold-eth";
|
||||
import { replacer } from "~~/utils/scaffold-eth/common";
|
||||
|
||||
type DisplayContent =
|
||||
| string
|
||||
| number
|
||||
| bigint
|
||||
| Record<string, any>
|
||||
| TransactionBase
|
||||
| TransactionReceipt
|
||||
| undefined
|
||||
| unknown;
|
||||
|
||||
type ResultFontSize = "sm" | "base" | "xs" | "lg" | "xl" | "2xl" | "3xl";
|
||||
|
||||
export const displayTxResult = (
|
||||
displayContent: DisplayContent | DisplayContent[],
|
||||
fontSize: ResultFontSize = "base",
|
||||
): string | ReactElement | number => {
|
||||
if (displayContent == null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (typeof displayContent === "bigint") {
|
||||
return <NumberDisplay value={displayContent} />;
|
||||
}
|
||||
|
||||
if (typeof displayContent === "string") {
|
||||
if (isAddress(displayContent)) {
|
||||
return <Address address={displayContent} size={fontSize} onlyEnsOrAddress />;
|
||||
}
|
||||
|
||||
if (isHex(displayContent)) {
|
||||
return displayContent; // don't add quotes
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(displayContent)) {
|
||||
return <ArrayDisplay values={displayContent} size={fontSize} />;
|
||||
}
|
||||
|
||||
if (typeof displayContent === "object") {
|
||||
return <StructDisplay struct={displayContent} size={fontSize} />;
|
||||
}
|
||||
|
||||
return JSON.stringify(displayContent, replacer, 2);
|
||||
};
|
||||
|
||||
const NumberDisplay = ({ value }: { value: bigint }) => {
|
||||
const [isEther, setIsEther] = useState(false);
|
||||
|
||||
const asNumber = Number(value);
|
||||
if (asNumber <= Number.MAX_SAFE_INTEGER && asNumber >= Number.MIN_SAFE_INTEGER) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-baseline">
|
||||
{isEther ? "Ξ" + formatEther(value) : String(value)}
|
||||
<span
|
||||
className="tooltip tooltip-secondary font-sans ml-2"
|
||||
data-tip={isEther ? "Multiply by 1e18" : "Divide by 1e18"}
|
||||
>
|
||||
<button className="btn btn-ghost btn-circle btn-xs" onClick={() => setIsEther(!isEther)}>
|
||||
<ArrowsRightLeftIcon className="h-3 w-3 opacity-65" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ObjectFieldDisplay = ({
|
||||
name,
|
||||
value,
|
||||
size,
|
||||
leftPad = true,
|
||||
}: {
|
||||
name: string;
|
||||
value: DisplayContent;
|
||||
size: ResultFontSize;
|
||||
leftPad?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div className={`flex flex-row items-baseline ${leftPad ? "ml-4" : ""}`}>
|
||||
<span className="text-base-content/60 mr-2">{name}:</span>
|
||||
<span className="text-base-content">{displayTxResult(value, size)}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ArrayDisplay = ({ values, size }: { values: DisplayContent[]; size: ResultFontSize }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
{values.length ? "array" : "[]"}
|
||||
{values.map((v, i) => (
|
||||
<ObjectFieldDisplay key={i} name={`[${i}]`} value={v} size={size} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StructDisplay = ({ struct, size }: { struct: Record<string, any>; size: ResultFontSize }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1">
|
||||
struct
|
||||
{Object.entries(struct).map(([k, v]) => (
|
||||
<ObjectFieldDisplay key={k} name={k} value={v} size={size} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
28
packages/nextjs/app/debug/page.tsx
Normal file
28
packages/nextjs/app/debug/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { DebugContracts } from "./_components/DebugContracts";
|
||||
import type { NextPage } from "next";
|
||||
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
|
||||
|
||||
export const metadata = getMetadata({
|
||||
title: "Debug Contracts",
|
||||
description: "Debug your deployed 🏗 Scaffold-ETH 2 contracts in an easy way",
|
||||
});
|
||||
|
||||
const Debug: NextPage = () => {
|
||||
return (
|
||||
<>
|
||||
<DebugContracts />
|
||||
<div className="text-center mt-8 bg-secondary p-10">
|
||||
<h1 className="text-4xl my-0">Debug Contracts</h1>
|
||||
<p className="text-neutral">
|
||||
You can debug & interact with your deployed contracts here.
|
||||
<br /> Check{" "}
|
||||
<code className="italic bg-base-300 text-base font-bold [word-spacing:-0.5rem] px-1">
|
||||
packages / nextjs / app / debug / page.tsx
|
||||
</code>{" "}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Debug;
|
||||
82
packages/nextjs/app/ipfsDownload/page.tsx
Normal file
82
packages/nextjs/app/ipfsDownload/page.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { lazy, useEffect, useState } from "react";
|
||||
import type { NextPage } from "next";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
import { getMetadataFromIPFS } from "~~/utils/tokenization/ipfs-fetch";
|
||||
|
||||
const LazyReactJson = lazy(() => import("react-json-view"));
|
||||
|
||||
const IpfsDownload: NextPage = () => {
|
||||
const [yourJSON, setYourJSON] = useState({});
|
||||
const [ipfsPath, setIpfsPath] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleIpfsDownload = async () => {
|
||||
setLoading(true);
|
||||
const notificationId = notification.loading("Getting data from IPFS");
|
||||
try {
|
||||
const metaData = await getMetadataFromIPFS(ipfsPath);
|
||||
notification.remove(notificationId);
|
||||
notification.success("Downloaded from IPFS");
|
||||
|
||||
setYourJSON(metaData);
|
||||
} catch (error) {
|
||||
notification.remove(notificationId);
|
||||
notification.error("Error downloading from IPFS");
|
||||
console.log(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center flex-col flex-grow pt-10">
|
||||
<h1 className="text-center mb-4">
|
||||
<span className="block text-4xl font-bold">Download from IPFS</span>
|
||||
</h1>
|
||||
<div className={`flex border-2 border-accent/95 bg-base-200 rounded-full text-accent w-96`}>
|
||||
<input
|
||||
className="input input-ghost focus:outline-none focus:bg-transparent focus:text-secondary-content h-[2.2rem] min-h-[2.2rem] px-4 border w-full font-medium placeholder:text-accent/50 text-secondary-content/75"
|
||||
placeholder="IPFS CID"
|
||||
value={ipfsPath}
|
||||
onChange={e => setIpfsPath(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className={`btn btn-secondary my-6 ${loading ? "loading" : ""}`}
|
||||
disabled={loading}
|
||||
onClick={handleIpfsDownload}
|
||||
>
|
||||
Download from IPFS
|
||||
</button>
|
||||
|
||||
{mounted && (
|
||||
<LazyReactJson
|
||||
style={{ padding: "1rem", borderRadius: "0.75rem" }}
|
||||
src={yourJSON}
|
||||
theme="solarized"
|
||||
enableClipboard={false}
|
||||
onEdit={edit => {
|
||||
setYourJSON(edit.updated_src);
|
||||
}}
|
||||
onAdd={add => {
|
||||
setYourJSON(add.updated_src);
|
||||
}}
|
||||
onDelete={del => {
|
||||
setYourJSON(del.updated_src);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IpfsDownload;
|
||||
81
packages/nextjs/app/ipfsUpload/page.tsx
Normal file
81
packages/nextjs/app/ipfsUpload/page.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { lazy, useEffect, useState } from "react";
|
||||
import type { NextPage } from "next";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
import { addToIPFS } from "~~/utils/tokenization/ipfs-fetch";
|
||||
import nftsMetadata from "~~/utils/tokenization/nftsMetadata";
|
||||
|
||||
const LazyReactJson = lazy(() => import("react-json-view"));
|
||||
|
||||
const IpfsUpload: NextPage = () => {
|
||||
const [yourJSON, setYourJSON] = useState<object>(nftsMetadata[0]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploadedIpfsPath, setUploadedIpfsPath] = useState("");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const handleIpfsUpload = async () => {
|
||||
setLoading(true);
|
||||
const notificationId = notification.loading("Uploading to IPFS...");
|
||||
try {
|
||||
const uploadedItem = await addToIPFS(yourJSON);
|
||||
notification.remove(notificationId);
|
||||
notification.success("Uploaded to IPFS");
|
||||
|
||||
setUploadedIpfsPath(uploadedItem.path);
|
||||
} catch (error) {
|
||||
notification.remove(notificationId);
|
||||
notification.error("Error uploading to IPFS");
|
||||
console.log(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center flex-col flex-grow pt-10">
|
||||
<h1 className="text-center mb-4">
|
||||
<span className="block text-4xl font-bold">Upload to IPFS</span>
|
||||
</h1>
|
||||
|
||||
{mounted && (
|
||||
<LazyReactJson
|
||||
style={{ padding: "1rem", borderRadius: "0.75rem" }}
|
||||
src={yourJSON}
|
||||
theme="solarized"
|
||||
enableClipboard={false}
|
||||
onEdit={edit => {
|
||||
setYourJSON(edit.updated_src);
|
||||
}}
|
||||
onAdd={add => {
|
||||
setYourJSON(add.updated_src);
|
||||
}}
|
||||
onDelete={del => {
|
||||
setYourJSON(del.updated_src);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
className={`btn btn-secondary mt-4 ${loading ? "loading" : ""}`}
|
||||
disabled={loading}
|
||||
onClick={handleIpfsUpload}
|
||||
>
|
||||
Upload to IPFS
|
||||
</button>
|
||||
{uploadedIpfsPath && (
|
||||
<div className="mt-4">
|
||||
<a href={`https://ipfs.io/ipfs/${uploadedIpfsPath}`} target="_blank" rel="noreferrer">
|
||||
{`https://ipfs.io/ipfs/${uploadedIpfsPath}`}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default IpfsUpload;
|
||||
30
packages/nextjs/app/layout.tsx
Normal file
30
packages/nextjs/app/layout.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Space_Grotesk } from "next/font/google";
|
||||
import "@rainbow-me/rainbowkit/styles.css";
|
||||
import { ScaffoldEthAppWithProviders } from "~~/components/ScaffoldEthAppWithProviders";
|
||||
import { ThemeProvider } from "~~/components/ThemeProvider";
|
||||
import "~~/styles/globals.css";
|
||||
import { getMetadata } from "~~/utils/scaffold-eth/getMetadata";
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-space-grotesk",
|
||||
});
|
||||
|
||||
export const metadata = getMetadata({
|
||||
title: "Tokenization | SpeedRunEthereum",
|
||||
description: "Built with 🏗 Scaffold-ETH 2",
|
||||
});
|
||||
|
||||
const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<html suppressHydrationWarning className={`${spaceGrotesk.variable} font-space-grotesk`}>
|
||||
<body>
|
||||
<ThemeProvider enableSystem>
|
||||
<ScaffoldEthAppWithProviders>{children}</ScaffoldEthAppWithProviders>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
};
|
||||
|
||||
export default ScaffoldEthApp;
|
||||
97
packages/nextjs/app/myNFTs/_components/MyHoldings.tsx
Normal file
97
packages/nextjs/app/myNFTs/_components/MyHoldings.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { NFTCard } from "./NFTCard";
|
||||
import { useAccount } from "wagmi";
|
||||
import { useScaffoldContract, useScaffoldReadContract } from "~~/hooks/scaffold-eth";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
import { getMetadataFromIPFS } from "~~/utils/tokenization/ipfs-fetch";
|
||||
import { NFTMetaData } from "~~/utils/tokenization/nftsMetadata";
|
||||
|
||||
export interface Collectible extends Partial<NFTMetaData> {
|
||||
id: number;
|
||||
uri: string;
|
||||
owner: string;
|
||||
}
|
||||
|
||||
export const MyHoldings = () => {
|
||||
const { address: connectedAddress } = useAccount();
|
||||
const [myAllCollectibles, setMyAllCollectibles] = useState<Collectible[]>([]);
|
||||
const [allCollectiblesLoading, setAllCollectiblesLoading] = useState(false);
|
||||
|
||||
const { data: yourCollectibleContract } = useScaffoldContract({
|
||||
contractName: "YourCollectible",
|
||||
});
|
||||
|
||||
const { data: myTotalBalance } = useScaffoldReadContract({
|
||||
contractName: "YourCollectible",
|
||||
functionName: "balanceOf",
|
||||
args: [connectedAddress],
|
||||
watch: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const updateMyCollectibles = async (): Promise<void> => {
|
||||
if (myTotalBalance === undefined || yourCollectibleContract === undefined || connectedAddress === undefined)
|
||||
return;
|
||||
|
||||
setAllCollectiblesLoading(true);
|
||||
const collectibleUpdate: Collectible[] = [];
|
||||
const totalBalance = parseInt(myTotalBalance.toString());
|
||||
for (let tokenIndex = 0; tokenIndex < totalBalance; tokenIndex++) {
|
||||
try {
|
||||
const tokenId = await yourCollectibleContract.read.tokenOfOwnerByIndex([
|
||||
connectedAddress,
|
||||
BigInt(tokenIndex),
|
||||
]);
|
||||
|
||||
const tokenURI = await yourCollectibleContract.read.tokenURI([tokenId]);
|
||||
|
||||
const ipfsHash = tokenURI.replace("https://ipfs.io/ipfs/", "");
|
||||
|
||||
const nftMetadata: NFTMetaData = await getMetadataFromIPFS(ipfsHash);
|
||||
|
||||
collectibleUpdate.push({
|
||||
id: parseInt(tokenId.toString()),
|
||||
uri: tokenURI,
|
||||
owner: connectedAddress,
|
||||
...nftMetadata,
|
||||
});
|
||||
} catch (e) {
|
||||
notification.error("Error fetching all collectibles");
|
||||
setAllCollectiblesLoading(false);
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
collectibleUpdate.sort((a, b) => a.id - b.id);
|
||||
setMyAllCollectibles(collectibleUpdate);
|
||||
setAllCollectiblesLoading(false);
|
||||
};
|
||||
|
||||
updateMyCollectibles();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [connectedAddress, myTotalBalance]);
|
||||
|
||||
if (allCollectiblesLoading)
|
||||
return (
|
||||
<div className="flex justify-center items-center mt-10">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{myAllCollectibles.length === 0 ? (
|
||||
<div className="flex justify-center items-center mt-10">
|
||||
<div className="text-2xl text-primary-content">No NFTs found</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-4 my-8 px-5 justify-center">
|
||||
{myAllCollectibles.map(item => (
|
||||
<NFTCard nft={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
66
packages/nextjs/app/myNFTs/_components/NFTCard.tsx
Normal file
66
packages/nextjs/app/myNFTs/_components/NFTCard.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useState } from "react";
|
||||
import { Collectible } from "./MyHoldings";
|
||||
import { Address, AddressInput } from "~~/components/scaffold-eth";
|
||||
import { useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
|
||||
|
||||
export const NFTCard = ({ nft }: { nft: Collectible }) => {
|
||||
const [transferToAddress, setTransferToAddress] = useState("");
|
||||
|
||||
const { writeContractAsync } = useScaffoldWriteContract({ contractName: "YourCollectible" });
|
||||
|
||||
return (
|
||||
<div className="card card-compact bg-base-100 shadow-lg w-[300px] shadow-secondary">
|
||||
<figure className="relative">
|
||||
{/* eslint-disable-next-line */}
|
||||
<img src={nft.image} alt="NFT Image" className="h-60 min-w-full" />
|
||||
<figcaption className="glass absolute bottom-4 left-4 p-4 rounded-xl">
|
||||
<span className="text-white "># {nft.id}</span>
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div className="card-body space-y-3">
|
||||
<div className="flex items-center justify-center">
|
||||
<p className="text-xl p-0 m-0 font-semibold">{nft.name}</p>
|
||||
<div className="flex flex-wrap space-x-2 mt-1">
|
||||
{nft.attributes?.map((attr, index) => (
|
||||
<span key={index} className="badge badge-primary px-1.5">
|
||||
{attr.value}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center mt-1">
|
||||
<p className="my-0 text-lg">{nft.description}</p>
|
||||
</div>
|
||||
<div className="flex space-x-3 mt-1 items-center">
|
||||
<span className="text-lg font-semibold">Owner : </span>
|
||||
<Address address={nft.owner} />
|
||||
</div>
|
||||
<div className="flex flex-col my-2 space-y-1">
|
||||
<span className="text-lg font-semibold mb-1">Transfer To: </span>
|
||||
<AddressInput
|
||||
value={transferToAddress}
|
||||
placeholder="receiver address"
|
||||
onChange={newValue => setTransferToAddress(newValue)}
|
||||
/>
|
||||
</div>
|
||||
<div className="card-actions justify-end">
|
||||
<button
|
||||
className="btn btn-secondary btn-md px-8 tracking-wide"
|
||||
onClick={() => {
|
||||
try {
|
||||
writeContractAsync({
|
||||
functionName: "transferFrom",
|
||||
args: [nft.owner, transferToAddress, BigInt(nft.id.toString())],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Error calling transferFrom function", err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
packages/nextjs/app/myNFTs/_components/index.ts
Normal file
1
packages/nextjs/app/myNFTs/_components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./MyHoldings";
|
||||
70
packages/nextjs/app/myNFTs/page.tsx
Normal file
70
packages/nextjs/app/myNFTs/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { MyHoldings } from "./_components";
|
||||
import type { NextPage } from "next";
|
||||
import { useAccount } from "wagmi";
|
||||
import { RainbowKitCustomConnectButton } from "~~/components/scaffold-eth";
|
||||
import { useScaffoldReadContract, useScaffoldWriteContract } from "~~/hooks/scaffold-eth";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
import { addToIPFS } from "~~/utils/tokenization/ipfs-fetch";
|
||||
import nftsMetadata from "~~/utils/tokenization/nftsMetadata";
|
||||
|
||||
const MyNFTs: NextPage = () => {
|
||||
const { address: connectedAddress, isConnected, isConnecting } = useAccount();
|
||||
|
||||
const { writeContractAsync } = useScaffoldWriteContract({ contractName: "YourCollectible" });
|
||||
|
||||
const { data: tokenIdCounter } = useScaffoldReadContract({
|
||||
contractName: "YourCollectible",
|
||||
functionName: "tokenIdCounter",
|
||||
watch: true,
|
||||
});
|
||||
|
||||
const handleMintItem = async () => {
|
||||
// circle back to the zero item if we've reached the end of the array
|
||||
if (tokenIdCounter === undefined) return;
|
||||
|
||||
const tokenIdCounterNumber = Number(tokenIdCounter);
|
||||
const currentTokenMetaData = nftsMetadata[tokenIdCounterNumber % nftsMetadata.length];
|
||||
const notificationId = notification.loading("Uploading to IPFS");
|
||||
try {
|
||||
const uploadedItem = await addToIPFS(currentTokenMetaData);
|
||||
|
||||
// First remove previous loading notification and then show success notification
|
||||
notification.remove(notificationId);
|
||||
notification.success("Metadata uploaded to IPFS");
|
||||
|
||||
await writeContractAsync({
|
||||
functionName: "mintItem",
|
||||
args: [connectedAddress, uploadedItem.path],
|
||||
});
|
||||
} catch (error) {
|
||||
notification.remove(notificationId);
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center flex-col pt-10">
|
||||
<div className="px-5">
|
||||
<h1 className="text-center mb-8">
|
||||
<span className="block text-4xl font-bold">My NFTs</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
{!isConnected || isConnecting ? (
|
||||
<RainbowKitCustomConnectButton />
|
||||
) : (
|
||||
<button className="btn btn-secondary" onClick={handleMintItem}>
|
||||
Mint NFT
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<MyHoldings />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyNFTs;
|
||||
16
packages/nextjs/app/not-found.tsx
Normal file
16
packages/nextjs/app/not-found.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import Link from "next/link";
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex items-center h-full flex-1 justify-center bg-base-200">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold m-0 mb-1">404</h1>
|
||||
<h2 className="text-2xl font-semibold m-0">Page Not Found</h2>
|
||||
<p className="text-base-content/70 m-0 mb-4">The page you're looking for doesn't exist.</p>
|
||||
<Link href="/" className="btn btn-primary">
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
98
packages/nextjs/app/page.tsx
Normal file
98
packages/nextjs/app/page.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { NextPage } from "next";
|
||||
import { useAccount } from "wagmi";
|
||||
import { BugAntIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { Address } from "~~/components/scaffold-eth";
|
||||
|
||||
const Home: NextPage = () => {
|
||||
const { address: connectedAddress } = useAccount();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center flex-col grow pt-10">
|
||||
<div className="px-5">
|
||||
<h1 className="text-center">
|
||||
<span className="block text-2xl mb-2">Welcome to</span>
|
||||
<span className="block text-4xl font-bold">Scaffold-ETH 2</span>
|
||||
<span className="block text-xl font-bold">(SpeedRunEthereum Challenge: Tokenization extension)</span>
|
||||
</h1>
|
||||
<div className="flex justify-center items-center space-x-2 flex-col">
|
||||
<p className="my-2 font-medium">Connected Address:</p>
|
||||
<Address address={connectedAddress} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-col flex-grow mt-4">
|
||||
<div className="px-5 w-[90%]">
|
||||
<h1 className="text-center mb-6">
|
||||
<span className="block text-4xl font-bold">Challenge: Tokenization</span>
|
||||
</h1>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<Image
|
||||
src="/hero.png"
|
||||
width="727"
|
||||
height="231"
|
||||
alt="challenge banner"
|
||||
className="rounded-xl border-4 border-primary"
|
||||
/>
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-center text-lg mt-8">
|
||||
🎫 Create a unique token to learn the basics of 🏗️ Scaffold-ETH 2. You'll use 👷♀️
|
||||
<a
|
||||
href="https://hardhat.org/getting-started/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="underline"
|
||||
>
|
||||
HardHat
|
||||
</a>{" "}
|
||||
to compile and deploy smart contracts. Then, you'll use a template React app full of important
|
||||
Ethereum components and hooks. Finally, you'll deploy an NFT to a public network to share with
|
||||
friends! 🚀
|
||||
</p>
|
||||
<p className="text-center text-lg">
|
||||
🌟 The final deliverable is an app that lets users purchase and transfer NFTs. Deploy your contracts
|
||||
to a testnet then build and upload your app to a public web server. Submit the url on{" "}
|
||||
<a href="https://speedrunethereum.com/" target="_blank" rel="noreferrer" className="underline">
|
||||
SpeedRunEthereum.com
|
||||
</a>{" "}
|
||||
!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grow bg-base-300 w-full mt-16 px-8 py-12">
|
||||
<div className="flex justify-center items-center gap-12 flex-col md:flex-row">
|
||||
<div className="flex flex-col bg-base-100 px-10 py-10 text-center items-center max-w-xs rounded-3xl">
|
||||
<BugAntIcon className="h-8 w-8 fill-secondary" />
|
||||
<p>
|
||||
Tinker with your smart contract using the{" "}
|
||||
<Link href="/debug" passHref className="link">
|
||||
Debug Contracts
|
||||
</Link>{" "}
|
||||
tab.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col bg-base-100 px-10 py-10 text-center items-center max-w-xs rounded-3xl">
|
||||
<MagnifyingGlassIcon className="h-8 w-8 fill-secondary" />
|
||||
<p>
|
||||
Explore your local transactions with the{" "}
|
||||
<Link href="/blockexplorer" passHref className="link">
|
||||
Block Explorer
|
||||
</Link>{" "}
|
||||
tab.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Home;
|
||||
67
packages/nextjs/app/transfers/page.tsx
Normal file
67
packages/nextjs/app/transfers/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import type { NextPage } from "next";
|
||||
import { Address } from "~~/components/scaffold-eth";
|
||||
import { useScaffoldEventHistory } from "~~/hooks/scaffold-eth";
|
||||
|
||||
const Transfers: NextPage = () => {
|
||||
const { data: transferEvents, isLoading } = useScaffoldEventHistory({
|
||||
contractName: "YourCollectible",
|
||||
eventName: "Transfer",
|
||||
});
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex justify-center items-center mt-10">
|
||||
<span className="loading loading-spinner loading-xl"></span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center flex-col flex-grow pt-10">
|
||||
<div className="px-5">
|
||||
<h1 className="text-center mb-8">
|
||||
<span className="block text-4xl font-bold">All Transfers Events</span>
|
||||
</h1>
|
||||
</div>
|
||||
<div className="overflow-x-auto shadow-lg">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr className="text-base-content">
|
||||
<th className="bg-primary">Token Id</th>
|
||||
<th className="bg-primary">From</th>
|
||||
<th className="bg-primary">To</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{!transferEvents || transferEvents.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="text-center">
|
||||
No events found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
transferEvents?.map((event, index) => {
|
||||
return (
|
||||
<tr key={index}>
|
||||
<th className="text-center">{event.args.tokenId?.toString()}</th>
|
||||
<td>
|
||||
<Address address={event.args.from} />
|
||||
</td>
|
||||
<td>
|
||||
<Address address={event.args.to} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Transfers;
|
||||
80
packages/nextjs/components/Footer.tsx
Normal file
80
packages/nextjs/components/Footer.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { CurrencyDollarIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { HeartIcon } from "@heroicons/react/24/outline";
|
||||
import { SwitchTheme } from "~~/components/SwitchTheme";
|
||||
import { BuidlGuidlLogo } from "~~/components/assets/BuidlGuidlLogo";
|
||||
import { Faucet } from "~~/components/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { useGlobalState } from "~~/services/store/store";
|
||||
|
||||
/**
|
||||
* Site footer
|
||||
*/
|
||||
export const Footer = () => {
|
||||
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const isLocalNetwork = targetNetwork.id === hardhat.id;
|
||||
|
||||
return (
|
||||
<div className="min-h-0 py-5 px-1 mb-11 lg:mb-0">
|
||||
<div>
|
||||
<div className="fixed flex justify-between items-center w-full z-10 p-4 bottom-0 left-0 pointer-events-none">
|
||||
<div className="flex flex-col md:flex-row gap-2 pointer-events-auto">
|
||||
{nativeCurrencyPrice > 0 && (
|
||||
<div>
|
||||
<div className="btn btn-primary btn-sm font-normal gap-1 cursor-auto">
|
||||
<CurrencyDollarIcon className="h-4 w-4" />
|
||||
<span>{nativeCurrencyPrice.toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isLocalNetwork && (
|
||||
<>
|
||||
<Faucet />
|
||||
<Link href="/blockexplorer" passHref className="btn btn-primary btn-sm font-normal gap-1">
|
||||
<MagnifyingGlassIcon className="h-4 w-4" />
|
||||
<span>Block Explorer</span>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SwitchTheme className={`pointer-events-auto ${isLocalNetwork ? "self-end md:self-auto" : ""}`} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<ul className="menu menu-horizontal w-full">
|
||||
<div className="flex justify-center items-center gap-2 text-sm w-full">
|
||||
<div className="text-center">
|
||||
<a href="https://github.com/scaffold-eth/se-2" target="_blank" rel="noreferrer" className="link">
|
||||
Fork me
|
||||
</a>
|
||||
</div>
|
||||
<span>·</span>
|
||||
<div className="flex justify-center items-center gap-2">
|
||||
<p className="m-0 text-center">
|
||||
Built with <HeartIcon className="inline-block h-4 w-4" /> at
|
||||
</p>
|
||||
<a
|
||||
className="flex justify-center items-center gap-1"
|
||||
href="https://buidlguidl.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<BuidlGuidlLogo className="w-3 h-5 pb-1" />
|
||||
<span className="link">BuidlGuidl</span>
|
||||
</a>
|
||||
</div>
|
||||
<span>·</span>
|
||||
<div className="text-center">
|
||||
<a href="https://t.me/joinchat/KByvmRe5wkR-8F_zz6AjpA" target="_blank" rel="noreferrer" className="link">
|
||||
Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
124
packages/nextjs/components/Header.tsx
Normal file
124
packages/nextjs/components/Header.tsx
Normal file
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import React, { useRef } from "react";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { Bars3Icon, BugAntIcon } from "@heroicons/react/24/outline";
|
||||
import { ArrowDownTrayIcon, ArrowPathIcon, ArrowUpTrayIcon, PhotoIcon } from "@heroicons/react/24/outline";
|
||||
import { FaucetButton, RainbowKitCustomConnectButton } from "~~/components/scaffold-eth";
|
||||
import { useOutsideClick, useTargetNetwork } from "~~/hooks/scaffold-eth";
|
||||
|
||||
type HeaderMenuLink = {
|
||||
label: string;
|
||||
href: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const menuLinks: HeaderMenuLink[] = [
|
||||
{
|
||||
label: "Home",
|
||||
href: "/",
|
||||
},
|
||||
{
|
||||
label: "My NFTs",
|
||||
href: "/myNFTs",
|
||||
icon: <PhotoIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "Transfers",
|
||||
href: "/transfers",
|
||||
icon: <ArrowPathIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "IPFS Upload",
|
||||
href: "/ipfsUpload",
|
||||
icon: <ArrowUpTrayIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "IPFS Download",
|
||||
href: "/ipfsDownload",
|
||||
icon: <ArrowDownTrayIcon className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "Debug Contracts",
|
||||
href: "/debug",
|
||||
icon: <BugAntIcon className="h-4 w-4" />,
|
||||
},
|
||||
];
|
||||
|
||||
export const HeaderMenuLinks = () => {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
{menuLinks.map(({ label, href, icon }) => {
|
||||
const isActive = pathname === href;
|
||||
return (
|
||||
<li key={href}>
|
||||
<Link
|
||||
href={href}
|
||||
passHref
|
||||
className={`${
|
||||
isActive ? "bg-secondary shadow-md" : ""
|
||||
} hover:bg-secondary hover:shadow-md focus:!bg-secondary active:!text-neutral py-1.5 px-3 text-sm rounded-full gap-2 grid grid-flow-col`}
|
||||
>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Site header
|
||||
*/
|
||||
export const Header = () => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const isLocalNetwork = targetNetwork.id === hardhat.id;
|
||||
|
||||
const burgerMenuRef = useRef<HTMLDetailsElement>(null);
|
||||
useOutsideClick(burgerMenuRef, () => {
|
||||
burgerMenuRef?.current?.removeAttribute("open");
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="sticky lg:static top-0 navbar bg-base-100 min-h-0 shrink-0 justify-between z-20 shadow-md shadow-secondary px-0 sm:px-2">
|
||||
<div className="navbar-start w-auto lg:w-1/2">
|
||||
<details className="dropdown" ref={burgerMenuRef}>
|
||||
<summary className="ml-1 btn btn-ghost lg:hidden hover:bg-transparent">
|
||||
<Bars3Icon className="h-1/2" />
|
||||
</summary>
|
||||
<ul
|
||||
className="menu menu-compact dropdown-content mt-3 p-2 shadow-sm bg-base-100 rounded-box w-52"
|
||||
onClick={() => {
|
||||
burgerMenuRef?.current?.removeAttribute("open");
|
||||
}}
|
||||
>
|
||||
<HeaderMenuLinks />
|
||||
</ul>
|
||||
</details>
|
||||
<Link href="/" passHref className="hidden lg:flex items-center gap-2 ml-4 mr-6 shrink-0">
|
||||
<div className="flex relative w-10 h-10">
|
||||
<Image alt="SE2 logo" className="cursor-pointer" fill src="/logo.svg" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-bold leading-tight">SRE Challenges</span>
|
||||
<span className="text-xs">Tokenization</span>
|
||||
</div>
|
||||
</Link>
|
||||
<ul className="hidden lg:flex lg:flex-nowrap menu menu-horizontal px-1 gap-2">
|
||||
<HeaderMenuLinks />
|
||||
</ul>
|
||||
</div>
|
||||
<div className="navbar-end grow mr-4">
|
||||
<RainbowKitCustomConnectButton />
|
||||
{isLocalNetwork && <FaucetButton />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
61
packages/nextjs/components/ScaffoldEthAppWithProviders.tsx
Normal file
61
packages/nextjs/components/ScaffoldEthAppWithProviders.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { RainbowKitProvider, darkTheme, lightTheme } from "@rainbow-me/rainbowkit";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { AppProgressBar as ProgressBar } from "next-nprogress-bar";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { WagmiProvider } from "wagmi";
|
||||
import { Footer } from "~~/components/Footer";
|
||||
import { Header } from "~~/components/Header";
|
||||
import { BlockieAvatar } from "~~/components/scaffold-eth";
|
||||
import { useInitializeNativeCurrencyPrice } from "~~/hooks/scaffold-eth";
|
||||
import { wagmiConfig } from "~~/services/web3/wagmiConfig";
|
||||
|
||||
const ScaffoldEthApp = ({ children }: { children: React.ReactNode }) => {
|
||||
useInitializeNativeCurrencyPrice();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`flex flex-col min-h-screen `}>
|
||||
<Header />
|
||||
<main className="relative flex flex-col flex-1">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
<Toaster />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ScaffoldEthAppWithProviders = ({ children }: { children: React.ReactNode }) => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<WagmiProvider config={wagmiConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RainbowKitProvider
|
||||
avatar={BlockieAvatar}
|
||||
theme={mounted ? (isDarkMode ? darkTheme() : lightTheme()) : lightTheme()}
|
||||
>
|
||||
<ProgressBar height="3px" color="#2299dd" />
|
||||
<ScaffoldEthApp>{children}</ScaffoldEthApp>
|
||||
</RainbowKitProvider>
|
||||
</QueryClientProvider>
|
||||
</WagmiProvider>
|
||||
);
|
||||
};
|
||||
42
packages/nextjs/components/SwitchTheme.tsx
Normal file
42
packages/nextjs/components/SwitchTheme.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { MoonIcon, SunIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const SwitchTheme = ({ className }: { className?: string }) => {
|
||||
const { setTheme, resolvedTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
const handleToggle = () => {
|
||||
if (isDarkMode) {
|
||||
setTheme("light");
|
||||
return;
|
||||
}
|
||||
setTheme("dark");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className={`flex space-x-2 h-8 items-center justify-center text-sm ${className}`}>
|
||||
<input
|
||||
id="theme-toggle"
|
||||
type="checkbox"
|
||||
className="toggle bg-secondary toggle-primary hover:bg-accent transition-all"
|
||||
onChange={handleToggle}
|
||||
checked={isDarkMode}
|
||||
/>
|
||||
<label htmlFor="theme-toggle" className={`swap swap-rotate ${!isDarkMode ? "swap-active" : ""}`}>
|
||||
<SunIcon className="swap-on h-5 w-5" />
|
||||
<MoonIcon className="swap-off h-5 w-5" />
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
9
packages/nextjs/components/ThemeProvider.tsx
Normal file
9
packages/nextjs/components/ThemeProvider.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export const ThemeProvider = ({ children, ...props }: ThemeProviderProps) => {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
};
|
||||
18
packages/nextjs/components/assets/BuidlGuidlLogo.tsx
Normal file
18
packages/nextjs/components/assets/BuidlGuidlLogo.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
export const BuidlGuidlLogo = ({ className }: { className: string }) => {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width="53"
|
||||
height="72"
|
||||
viewBox="0 0 53 72"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M25.9 17.434v15.638h3.927v9.04h9.718v-9.04h6.745v18.08l-10.607 19.88-12.11-.182-12.11.183L.856 51.152v-18.08h6.713v9.04h9.75v-9.04h4.329V2.46a2.126 2.126 0 0 1 4.047-.914c1.074.412 2.157 1.5 3.276 2.626 1.33 1.337 2.711 2.726 4.193 3.095 1.496.373 2.605-.026 3.855-.475 1.31-.47 2.776-.997 5.005-.747 1.67.197 2.557 1.289 3.548 2.509 1.317 1.623 2.82 3.473 6.599 3.752l-.024.017c-2.42 1.709-5.726 4.043-10.86 3.587-1.605-.139-2.736-.656-3.82-1.153-1.546-.707-2.997-1.37-5.59-.832-2.809.563-4.227 1.892-5.306 2.903-.236.221-.456.427-.67.606Z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
187
packages/nextjs/components/scaffold-eth/Address/Address.tsx
Normal file
187
packages/nextjs/components/scaffold-eth/Address/Address.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
"use client";
|
||||
|
||||
import { AddressCopyIcon } from "./AddressCopyIcon";
|
||||
import { AddressLinkWrapper } from "./AddressLinkWrapper";
|
||||
import { Address as AddressType, getAddress, isAddress } from "viem";
|
||||
import { normalize } from "viem/ens";
|
||||
import { useEnsAvatar, useEnsName } from "wagmi";
|
||||
import { BlockieAvatar } from "~~/components/scaffold-eth";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { getBlockExplorerAddressLink } from "~~/utils/scaffold-eth";
|
||||
|
||||
const textSizeMap = {
|
||||
"3xs": "text-[10px]",
|
||||
"2xs": "text-[11px]",
|
||||
xs: "text-xs",
|
||||
sm: "text-sm",
|
||||
base: "text-base",
|
||||
lg: "text-lg",
|
||||
xl: "text-xl",
|
||||
"2xl": "text-2xl",
|
||||
"3xl": "text-3xl",
|
||||
"4xl": "text-4xl",
|
||||
} as const;
|
||||
|
||||
const blockieSizeMap = {
|
||||
"3xs": 4,
|
||||
"2xs": 5,
|
||||
xs: 6,
|
||||
sm: 7,
|
||||
base: 8,
|
||||
lg: 9,
|
||||
xl: 10,
|
||||
"2xl": 12,
|
||||
"3xl": 15,
|
||||
"4xl": 17,
|
||||
"5xl": 19,
|
||||
"6xl": 21,
|
||||
"7xl": 23,
|
||||
} as const;
|
||||
|
||||
const copyIconSizeMap = {
|
||||
"3xs": "h-2.5 w-2.5",
|
||||
"2xs": "h-3 w-3",
|
||||
xs: "h-3.5 w-3.5",
|
||||
sm: "h-4 w-4",
|
||||
base: "h-[18px] w-[18px]",
|
||||
lg: "h-5 w-5",
|
||||
xl: "h-[22px] w-[22px]",
|
||||
"2xl": "h-6 w-6",
|
||||
"3xl": "h-[26px] w-[26px]",
|
||||
"4xl": "h-7 w-7",
|
||||
} as const;
|
||||
|
||||
type SizeMap = typeof textSizeMap | typeof blockieSizeMap;
|
||||
|
||||
const getNextSize = <T extends SizeMap>(sizeMap: T, currentSize: keyof T, step = 1): keyof T => {
|
||||
const sizes = Object.keys(sizeMap) as Array<keyof T>;
|
||||
const currentIndex = sizes.indexOf(currentSize);
|
||||
const nextIndex = Math.min(currentIndex + step, sizes.length - 1);
|
||||
return sizes[nextIndex];
|
||||
};
|
||||
|
||||
const getPrevSize = <T extends SizeMap>(sizeMap: T, currentSize: keyof T, step = 1): keyof T => {
|
||||
const sizes = Object.keys(sizeMap) as Array<keyof T>;
|
||||
const currentIndex = sizes.indexOf(currentSize);
|
||||
const prevIndex = Math.max(currentIndex - step, 0);
|
||||
return sizes[prevIndex];
|
||||
};
|
||||
|
||||
type AddressProps = {
|
||||
address?: AddressType;
|
||||
disableAddressLink?: boolean;
|
||||
format?: "short" | "long";
|
||||
size?: "xs" | "sm" | "base" | "lg" | "xl" | "2xl" | "3xl";
|
||||
onlyEnsOrAddress?: boolean;
|
||||
};
|
||||
|
||||
export const Address = ({
|
||||
address,
|
||||
disableAddressLink,
|
||||
format,
|
||||
size = "base",
|
||||
onlyEnsOrAddress = false,
|
||||
}: AddressProps) => {
|
||||
const checkSumAddress = address ? getAddress(address) : undefined;
|
||||
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
|
||||
const { data: ens, isLoading: isEnsNameLoading } = useEnsName({
|
||||
address: checkSumAddress,
|
||||
chainId: 1,
|
||||
query: {
|
||||
enabled: isAddress(checkSumAddress ?? ""),
|
||||
},
|
||||
});
|
||||
const { data: ensAvatar } = useEnsAvatar({
|
||||
name: ens ? normalize(ens) : undefined,
|
||||
chainId: 1,
|
||||
query: {
|
||||
enabled: Boolean(ens),
|
||||
gcTime: 30_000,
|
||||
},
|
||||
});
|
||||
|
||||
const shortAddress = checkSumAddress?.slice(0, 6) + "..." + checkSumAddress?.slice(-4);
|
||||
const displayAddress = format === "long" ? checkSumAddress : shortAddress;
|
||||
const displayEnsOrAddress = ens || displayAddress;
|
||||
|
||||
const showSkeleton = !checkSumAddress || (!onlyEnsOrAddress && (ens || isEnsNameLoading));
|
||||
|
||||
const addressSize = showSkeleton && !onlyEnsOrAddress ? getPrevSize(textSizeMap, size, 2) : size;
|
||||
const ensSize = getNextSize(textSizeMap, addressSize);
|
||||
const blockieSize = showSkeleton && !onlyEnsOrAddress ? getNextSize(blockieSizeMap, addressSize, 4) : addressSize;
|
||||
|
||||
if (!checkSumAddress) {
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className="shrink-0 skeleton rounded-full"
|
||||
style={{
|
||||
width: (blockieSizeMap[blockieSize] * 24) / blockieSizeMap["base"],
|
||||
height: (blockieSizeMap[blockieSize] * 24) / blockieSizeMap["base"],
|
||||
}}
|
||||
></div>
|
||||
<div className="flex flex-col space-y-1">
|
||||
{!onlyEnsOrAddress && (
|
||||
<div className={`ml-1.5 skeleton rounded-lg font-bold ${textSizeMap[ensSize]}`}>
|
||||
<span className="invisible">0x1234...56789</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`ml-1.5 skeleton rounded-lg ${textSizeMap[addressSize]}`}>
|
||||
<span className="invisible">0x1234...56789</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAddress(checkSumAddress)) {
|
||||
return <span className="text-error">Wrong address</span>;
|
||||
}
|
||||
|
||||
const blockExplorerAddressLink = getBlockExplorerAddressLink(targetNetwork, checkSumAddress);
|
||||
|
||||
return (
|
||||
<div className="flex items-center shrink-0">
|
||||
<div className="shrink-0">
|
||||
<BlockieAvatar
|
||||
address={checkSumAddress}
|
||||
ensImage={ensAvatar}
|
||||
size={(blockieSizeMap[blockieSize] * 24) / blockieSizeMap["base"]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
{showSkeleton &&
|
||||
(isEnsNameLoading ? (
|
||||
<div className={`ml-1.5 skeleton rounded-lg font-bold ${textSizeMap[ensSize]}`}>
|
||||
<span className="invisible">{shortAddress}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className={`ml-1.5 ${textSizeMap[ensSize]} font-bold`}>
|
||||
<AddressLinkWrapper
|
||||
disableAddressLink={disableAddressLink}
|
||||
blockExplorerAddressLink={blockExplorerAddressLink}
|
||||
>
|
||||
{ens}
|
||||
</AddressLinkWrapper>
|
||||
</span>
|
||||
))}
|
||||
<div className="flex">
|
||||
<span className={`ml-1.5 ${textSizeMap[addressSize]} font-normal`}>
|
||||
<AddressLinkWrapper
|
||||
disableAddressLink={disableAddressLink}
|
||||
blockExplorerAddressLink={blockExplorerAddressLink}
|
||||
>
|
||||
{onlyEnsOrAddress ? displayEnsOrAddress : displayAddress}
|
||||
</AddressLinkWrapper>
|
||||
</span>
|
||||
<AddressCopyIcon
|
||||
className={`ml-1 ${copyIconSizeMap[addressSize]} cursor-pointer`}
|
||||
address={checkSumAddress}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CheckCircleIcon, DocumentDuplicateIcon } from "@heroicons/react/24/outline";
|
||||
import { useCopyToClipboard } from "~~/hooks/scaffold-eth/useCopyToClipboard";
|
||||
|
||||
export const AddressCopyIcon = ({ className, address }: { className?: string; address: string }) => {
|
||||
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
|
||||
useCopyToClipboard();
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
copyAddressToClipboard(address);
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{isAddressCopiedToClipboard ? (
|
||||
<CheckCircleIcon className={className} aria-hidden="true" />
|
||||
) : (
|
||||
<DocumentDuplicateIcon className={className} aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import Link from "next/link";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth";
|
||||
|
||||
type AddressLinkWrapperProps = {
|
||||
children: React.ReactNode;
|
||||
disableAddressLink?: boolean;
|
||||
blockExplorerAddressLink: string;
|
||||
};
|
||||
|
||||
export const AddressLinkWrapper = ({
|
||||
children,
|
||||
disableAddressLink,
|
||||
blockExplorerAddressLink,
|
||||
}: AddressLinkWrapperProps) => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
|
||||
return disableAddressLink ? (
|
||||
<>{children}</>
|
||||
) : (
|
||||
<Link
|
||||
href={blockExplorerAddressLink}
|
||||
target={targetNetwork.id === hardhat.id ? undefined : "_blank"}
|
||||
rel={targetNetwork.id === hardhat.id ? undefined : "noopener noreferrer"}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
75
packages/nextjs/components/scaffold-eth/Balance.tsx
Normal file
75
packages/nextjs/components/scaffold-eth/Balance.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { Address, formatEther } from "viem";
|
||||
import { useDisplayUsdMode } from "~~/hooks/scaffold-eth/useDisplayUsdMode";
|
||||
import { useTargetNetwork } from "~~/hooks/scaffold-eth/useTargetNetwork";
|
||||
import { useWatchBalance } from "~~/hooks/scaffold-eth/useWatchBalance";
|
||||
import { useGlobalState } from "~~/services/store/store";
|
||||
|
||||
type BalanceProps = {
|
||||
address?: Address;
|
||||
className?: string;
|
||||
usdMode?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Display (ETH & USD) balance of an ETH address.
|
||||
*/
|
||||
export const Balance = ({ address, className = "", usdMode }: BalanceProps) => {
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
|
||||
const isNativeCurrencyPriceFetching = useGlobalState(state => state.nativeCurrency.isFetching);
|
||||
|
||||
const {
|
||||
data: balance,
|
||||
isError,
|
||||
isLoading,
|
||||
} = useWatchBalance({
|
||||
address,
|
||||
});
|
||||
|
||||
const { displayUsdMode, toggleDisplayUsdMode } = useDisplayUsdMode({ defaultUsdMode: usdMode });
|
||||
|
||||
if (!address || isLoading || balance === null || (isNativeCurrencyPriceFetching && nativeCurrencyPrice === 0)) {
|
||||
return (
|
||||
<div className="animate-pulse flex space-x-4">
|
||||
<div className="rounded-md bg-slate-300 h-6 w-6"></div>
|
||||
<div className="flex items-center space-y-6">
|
||||
<div className="h-2 w-28 bg-slate-300 rounded-sm"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="border-2 border-base-content/30 rounded-md px-2 flex flex-col items-center max-w-fit cursor-pointer">
|
||||
<div className="text-warning">Error</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const formattedBalance = balance ? Number(formatEther(balance.value)) : 0;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`btn btn-sm btn-ghost flex flex-col font-normal items-center hover:bg-transparent ${className}`}
|
||||
onClick={toggleDisplayUsdMode}
|
||||
type="button"
|
||||
>
|
||||
<div className="w-full flex items-center justify-center">
|
||||
{displayUsdMode ? (
|
||||
<>
|
||||
<span className="text-[0.8em] font-bold mr-1">$</span>
|
||||
<span>{(formattedBalance * nativeCurrencyPrice).toFixed(2)}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span>{formattedBalance.toFixed(4)}</span>
|
||||
<span className="text-[0.8em] font-bold ml-1">{targetNetwork.nativeCurrency.symbol}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
17
packages/nextjs/components/scaffold-eth/BlockieAvatar.tsx
Normal file
17
packages/nextjs/components/scaffold-eth/BlockieAvatar.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { AvatarComponent } from "@rainbow-me/rainbowkit";
|
||||
import { blo } from "blo";
|
||||
|
||||
// Custom Avatar for RainbowKit
|
||||
export const BlockieAvatar: AvatarComponent = ({ address, ensImage, size }) => (
|
||||
// Don't want to use nextJS Image here (and adding remote patterns for the URL)
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
className="rounded-full"
|
||||
src={ensImage || blo(address as `0x${string}`)}
|
||||
width={size}
|
||||
height={size}
|
||||
alt={`${address} avatar`}
|
||||
/>
|
||||
);
|
||||
129
packages/nextjs/components/scaffold-eth/Faucet.tsx
Normal file
129
packages/nextjs/components/scaffold-eth/Faucet.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Address as AddressType, createWalletClient, http, parseEther } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useAccount } from "wagmi";
|
||||
import { BanknotesIcon } from "@heroicons/react/24/outline";
|
||||
import { Address, AddressInput, Balance, EtherInput } from "~~/components/scaffold-eth";
|
||||
import { useTransactor } from "~~/hooks/scaffold-eth";
|
||||
import { notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
// Account index to use from generated hardhat accounts.
|
||||
const FAUCET_ACCOUNT_INDEX = 0;
|
||||
|
||||
const localWalletClient = createWalletClient({
|
||||
chain: hardhat,
|
||||
transport: http(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Faucet modal which lets you send ETH to any address.
|
||||
*/
|
||||
export const Faucet = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [inputAddress, setInputAddress] = useState<AddressType>();
|
||||
const [faucetAddress, setFaucetAddress] = useState<AddressType>();
|
||||
const [sendValue, setSendValue] = useState("");
|
||||
|
||||
const { chain: ConnectedChain } = useAccount();
|
||||
|
||||
const faucetTxn = useTransactor(localWalletClient);
|
||||
|
||||
useEffect(() => {
|
||||
const getFaucetAddress = async () => {
|
||||
try {
|
||||
const accounts = await localWalletClient.getAddresses();
|
||||
setFaucetAddress(accounts[FAUCET_ACCOUNT_INDEX]);
|
||||
} catch (error) {
|
||||
notification.error(
|
||||
<>
|
||||
<p className="font-bold mt-0 mb-1">Cannot connect to local provider</p>
|
||||
<p className="m-0">
|
||||
- Did you forget to run <code className="italic bg-base-300 text-base font-bold">yarn chain</code> ?
|
||||
</p>
|
||||
<p className="mt-1 break-normal">
|
||||
- Or you can change <code className="italic bg-base-300 text-base font-bold">targetNetwork</code> in{" "}
|
||||
<code className="italic bg-base-300 text-base font-bold">scaffold.config.ts</code>
|
||||
</p>
|
||||
</>,
|
||||
);
|
||||
console.error("⚡️ ~ file: Faucet.tsx:getFaucetAddress ~ error", error);
|
||||
}
|
||||
};
|
||||
getFaucetAddress();
|
||||
}, []);
|
||||
|
||||
const sendETH = async () => {
|
||||
if (!faucetAddress || !inputAddress) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setLoading(true);
|
||||
await faucetTxn({
|
||||
to: inputAddress,
|
||||
value: parseEther(sendValue as `${number}`),
|
||||
account: faucetAddress,
|
||||
});
|
||||
setLoading(false);
|
||||
setInputAddress(undefined);
|
||||
setSendValue("");
|
||||
} catch (error) {
|
||||
console.error("⚡️ ~ file: Faucet.tsx:sendETH ~ error", error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Render only on local chain
|
||||
if (ConnectedChain?.id !== hardhat.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label htmlFor="faucet-modal" className="btn btn-primary btn-sm font-normal gap-1">
|
||||
<BanknotesIcon className="h-4 w-4" />
|
||||
<span>Faucet</span>
|
||||
</label>
|
||||
<input type="checkbox" id="faucet-modal" className="modal-toggle" />
|
||||
<label htmlFor="faucet-modal" className="modal cursor-pointer">
|
||||
<label className="modal-box relative">
|
||||
{/* dummy input to capture event onclick on modal box */}
|
||||
<input className="h-0 w-0 absolute top-0 left-0" />
|
||||
<h3 className="text-xl font-bold mb-3">Local Faucet</h3>
|
||||
<label htmlFor="faucet-modal" className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
|
||||
✕
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<div className="flex space-x-4">
|
||||
<div>
|
||||
<span className="text-sm font-bold">From:</span>
|
||||
<Address address={faucetAddress} onlyEnsOrAddress />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-bold pl-3">Available:</span>
|
||||
<Balance address={faucetAddress} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col space-y-3">
|
||||
<AddressInput
|
||||
placeholder="Destination Address"
|
||||
value={inputAddress ?? ""}
|
||||
onChange={value => setInputAddress(value as AddressType)}
|
||||
/>
|
||||
<EtherInput placeholder="Amount to send" value={sendValue} onChange={value => setSendValue(value)} />
|
||||
<button className="h-10 btn btn-primary btn-sm px-2 rounded-full" onClick={sendETH} disabled={loading}>
|
||||
{!loading ? (
|
||||
<BanknotesIcon className="h-6 w-6" />
|
||||
) : (
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
)}
|
||||
<span>Send</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
73
packages/nextjs/components/scaffold-eth/FaucetButton.tsx
Normal file
73
packages/nextjs/components/scaffold-eth/FaucetButton.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { createWalletClient, http, parseEther } from "viem";
|
||||
import { hardhat } from "viem/chains";
|
||||
import { useAccount } from "wagmi";
|
||||
import { BanknotesIcon } from "@heroicons/react/24/outline";
|
||||
import { useTransactor } from "~~/hooks/scaffold-eth";
|
||||
import { useWatchBalance } from "~~/hooks/scaffold-eth/useWatchBalance";
|
||||
|
||||
// Number of ETH faucet sends to an address
|
||||
const NUM_OF_ETH = "1";
|
||||
const FAUCET_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
|
||||
|
||||
const localWalletClient = createWalletClient({
|
||||
chain: hardhat,
|
||||
transport: http(),
|
||||
});
|
||||
|
||||
/**
|
||||
* FaucetButton button which lets you grab eth.
|
||||
*/
|
||||
export const FaucetButton = () => {
|
||||
const { address, chain: ConnectedChain } = useAccount();
|
||||
|
||||
const { data: balance } = useWatchBalance({ address });
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const faucetTxn = useTransactor(localWalletClient);
|
||||
|
||||
const sendETH = async () => {
|
||||
if (!address) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
await faucetTxn({
|
||||
account: FAUCET_ADDRESS,
|
||||
to: address,
|
||||
value: parseEther(NUM_OF_ETH),
|
||||
});
|
||||
setLoading(false);
|
||||
} catch (error) {
|
||||
console.error("⚡️ ~ file: FaucetButton.tsx:sendETH ~ error", error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Render only on local chain
|
||||
if (ConnectedChain?.id !== hardhat.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isBalanceZero = balance && balance.value === 0n;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
!isBalanceZero
|
||||
? "ml-1"
|
||||
: "ml-1 tooltip tooltip-bottom tooltip-primary tooltip-open font-bold before:left-auto before:transform-none before:content-[attr(data-tip)] before:-translate-x-2/5"
|
||||
}
|
||||
data-tip="Grab funds from faucet"
|
||||
>
|
||||
<button className="btn btn-secondary btn-sm px-2 rounded-full" onClick={sendETH} disabled={loading}>
|
||||
{!loading ? (
|
||||
<BanknotesIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
112
packages/nextjs/components/scaffold-eth/Input/AddressInput.tsx
Normal file
112
packages/nextjs/components/scaffold-eth/Input/AddressInput.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { blo } from "blo";
|
||||
import { useDebounceValue } from "usehooks-ts";
|
||||
import { Address, isAddress } from "viem";
|
||||
import { normalize } from "viem/ens";
|
||||
import { useEnsAddress, useEnsAvatar, useEnsName } from "wagmi";
|
||||
import { CommonInputProps, InputBase, isENS } from "~~/components/scaffold-eth";
|
||||
|
||||
/**
|
||||
* Address input with ENS name resolution
|
||||
*/
|
||||
export const AddressInput = ({ value, name, placeholder, onChange, disabled }: CommonInputProps<Address | string>) => {
|
||||
// Debounce the input to keep clean RPC calls when resolving ENS names
|
||||
// If the input is an address, we don't need to debounce it
|
||||
const [_debouncedValue] = useDebounceValue(value, 500);
|
||||
const debouncedValue = isAddress(value) ? value : _debouncedValue;
|
||||
const isDebouncedValueLive = debouncedValue === value;
|
||||
|
||||
// If the user changes the input after an ENS name is already resolved, we want to remove the stale result
|
||||
const settledValue = isDebouncedValueLive ? debouncedValue : undefined;
|
||||
|
||||
const {
|
||||
data: ensAddress,
|
||||
isLoading: isEnsAddressLoading,
|
||||
isError: isEnsAddressError,
|
||||
} = useEnsAddress({
|
||||
name: settledValue,
|
||||
chainId: 1,
|
||||
query: {
|
||||
gcTime: 30_000,
|
||||
enabled: isDebouncedValueLive && isENS(debouncedValue),
|
||||
},
|
||||
});
|
||||
|
||||
const [enteredEnsName, setEnteredEnsName] = useState<string>();
|
||||
const {
|
||||
data: ensName,
|
||||
isLoading: isEnsNameLoading,
|
||||
isError: isEnsNameError,
|
||||
} = useEnsName({
|
||||
address: settledValue as Address,
|
||||
chainId: 1,
|
||||
query: {
|
||||
enabled: isAddress(debouncedValue),
|
||||
gcTime: 30_000,
|
||||
},
|
||||
});
|
||||
|
||||
const { data: ensAvatar, isLoading: isEnsAvatarLoading } = useEnsAvatar({
|
||||
name: ensName ? normalize(ensName) : undefined,
|
||||
chainId: 1,
|
||||
query: {
|
||||
enabled: Boolean(ensName),
|
||||
gcTime: 30_000,
|
||||
},
|
||||
});
|
||||
|
||||
// ens => address
|
||||
useEffect(() => {
|
||||
if (!ensAddress) return;
|
||||
|
||||
// ENS resolved successfully
|
||||
setEnteredEnsName(debouncedValue);
|
||||
onChange(ensAddress);
|
||||
}, [ensAddress, onChange, debouncedValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setEnteredEnsName(undefined);
|
||||
}, [value]);
|
||||
|
||||
const reFocus = isEnsAddressError || isEnsNameError || ensName === null || ensAddress === null;
|
||||
|
||||
return (
|
||||
<InputBase<Address>
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
error={ensAddress === null}
|
||||
value={value as Address}
|
||||
onChange={onChange}
|
||||
disabled={isEnsAddressLoading || isEnsNameLoading || disabled}
|
||||
reFocus={reFocus}
|
||||
prefix={
|
||||
ensName ? (
|
||||
<div className="flex bg-base-300 rounded-l-full items-center">
|
||||
{isEnsAvatarLoading && <div className="skeleton bg-base-200 w-[35px] h-[35px] rounded-full shrink-0"></div>}
|
||||
{ensAvatar ? (
|
||||
<span className="w-[35px]">
|
||||
{
|
||||
// eslint-disable-next-line
|
||||
<img className="w-full rounded-full" src={ensAvatar} alt={`${ensAddress} avatar`} />
|
||||
}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="text-accent px-2">{enteredEnsName ?? ensName}</span>
|
||||
</div>
|
||||
) : (
|
||||
(isEnsNameLoading || isEnsAddressLoading) && (
|
||||
<div className="flex bg-base-300 rounded-l-full items-center gap-2 pr-2">
|
||||
<div className="skeleton bg-base-200 w-[35px] h-[35px] rounded-full shrink-0"></div>
|
||||
<div className="skeleton bg-base-200 h-3 w-20"></div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
}
|
||||
suffix={
|
||||
// Don't want to use nextJS Image here (and adding remote patterns for the URL)
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
value && <img alt="" className="rounded-full!" src={blo(value as `0x${string}`)} width="35" height="35" />
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useCallback } from "react";
|
||||
import { hexToString, isHex, stringToHex } from "viem";
|
||||
import { CommonInputProps, InputBase } from "~~/components/scaffold-eth";
|
||||
|
||||
export const Bytes32Input = ({ value, onChange, name, placeholder, disabled }: CommonInputProps) => {
|
||||
const convertStringToBytes32 = useCallback(() => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
onChange(isHex(value) ? hexToString(value, { size: 32 }) : stringToHex(value, { size: 32 }));
|
||||
}, [onChange, value]);
|
||||
|
||||
return (
|
||||
<InputBase
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
suffix={
|
||||
<button
|
||||
className="self-center cursor-pointer text-xl font-semibold px-4 text-accent"
|
||||
onClick={convertStringToBytes32}
|
||||
type="button"
|
||||
>
|
||||
#
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
28
packages/nextjs/components/scaffold-eth/Input/BytesInput.tsx
Normal file
28
packages/nextjs/components/scaffold-eth/Input/BytesInput.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useCallback } from "react";
|
||||
import { bytesToString, isHex, toBytes, toHex } from "viem";
|
||||
import { CommonInputProps, InputBase } from "~~/components/scaffold-eth";
|
||||
|
||||
export const BytesInput = ({ value, onChange, name, placeholder, disabled }: CommonInputProps) => {
|
||||
const convertStringToBytes = useCallback(() => {
|
||||
onChange(isHex(value) ? bytesToString(toBytes(value)) : toHex(toBytes(value)));
|
||||
}, [onChange, value]);
|
||||
|
||||
return (
|
||||
<InputBase
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
suffix={
|
||||
<button
|
||||
className="self-center cursor-pointer text-xl font-semibold px-4 text-accent"
|
||||
onClick={convertStringToBytes}
|
||||
type="button"
|
||||
>
|
||||
#
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
128
packages/nextjs/components/scaffold-eth/Input/EtherInput.tsx
Normal file
128
packages/nextjs/components/scaffold-eth/Input/EtherInput.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ArrowsRightLeftIcon } from "@heroicons/react/24/outline";
|
||||
import { CommonInputProps, InputBase, SIGNED_NUMBER_REGEX } from "~~/components/scaffold-eth";
|
||||
import { useDisplayUsdMode } from "~~/hooks/scaffold-eth/useDisplayUsdMode";
|
||||
import { useGlobalState } from "~~/services/store/store";
|
||||
|
||||
const MAX_DECIMALS_USD = 2;
|
||||
|
||||
function etherValueToDisplayValue(usdMode: boolean, etherValue: string, nativeCurrencyPrice: number) {
|
||||
if (usdMode && nativeCurrencyPrice) {
|
||||
const parsedEthValue = parseFloat(etherValue);
|
||||
if (Number.isNaN(parsedEthValue)) {
|
||||
return etherValue;
|
||||
} else {
|
||||
// We need to round the value rather than use toFixed,
|
||||
// since otherwise a user would not be able to modify the decimal value
|
||||
return (
|
||||
Math.round(parsedEthValue * nativeCurrencyPrice * 10 ** MAX_DECIMALS_USD) /
|
||||
10 ** MAX_DECIMALS_USD
|
||||
).toString();
|
||||
}
|
||||
} else {
|
||||
return etherValue;
|
||||
}
|
||||
}
|
||||
|
||||
function displayValueToEtherValue(usdMode: boolean, displayValue: string, nativeCurrencyPrice: number) {
|
||||
if (usdMode && nativeCurrencyPrice) {
|
||||
const parsedDisplayValue = parseFloat(displayValue);
|
||||
if (Number.isNaN(parsedDisplayValue)) {
|
||||
// Invalid number.
|
||||
return displayValue;
|
||||
} else {
|
||||
// Compute the ETH value if a valid number.
|
||||
return (parsedDisplayValue / nativeCurrencyPrice).toString();
|
||||
}
|
||||
} else {
|
||||
return displayValue;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for ETH amount with USD conversion.
|
||||
*
|
||||
* onChange will always be called with the value in ETH
|
||||
*/
|
||||
export const EtherInput = ({
|
||||
value,
|
||||
name,
|
||||
placeholder,
|
||||
onChange,
|
||||
disabled,
|
||||
usdMode,
|
||||
}: CommonInputProps & { usdMode?: boolean }) => {
|
||||
const [transitoryDisplayValue, setTransitoryDisplayValue] = useState<string>();
|
||||
const nativeCurrencyPrice = useGlobalState(state => state.nativeCurrency.price);
|
||||
const isNativeCurrencyPriceFetching = useGlobalState(state => state.nativeCurrency.isFetching);
|
||||
|
||||
const { displayUsdMode, toggleDisplayUsdMode } = useDisplayUsdMode({ defaultUsdMode: usdMode });
|
||||
|
||||
// The displayValue is derived from the ether value that is controlled outside of the component
|
||||
// In usdMode, it is converted to its usd value, in regular mode it is unaltered
|
||||
const displayValue = useMemo(() => {
|
||||
const newDisplayValue = etherValueToDisplayValue(displayUsdMode, value, nativeCurrencyPrice || 0);
|
||||
if (transitoryDisplayValue && parseFloat(newDisplayValue) === parseFloat(transitoryDisplayValue)) {
|
||||
return transitoryDisplayValue;
|
||||
}
|
||||
// Clear any transitory display values that might be set
|
||||
setTransitoryDisplayValue(undefined);
|
||||
return newDisplayValue;
|
||||
}, [nativeCurrencyPrice, transitoryDisplayValue, displayUsdMode, value]);
|
||||
|
||||
const handleChangeNumber = (newValue: string) => {
|
||||
if (newValue && !SIGNED_NUMBER_REGEX.test(newValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Following condition is a fix to prevent usdMode from experiencing different display values
|
||||
// than what the user entered. This can happen due to floating point rounding errors that are introduced in the back and forth conversion
|
||||
if (displayUsdMode) {
|
||||
const decimals = newValue.split(".")[1];
|
||||
if (decimals && decimals.length > MAX_DECIMALS_USD) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Since the display value is a derived state (calculated from the ether value), usdMode would not allow introducing a decimal point.
|
||||
// This condition handles a transitory state for a display value with a trailing decimal sign
|
||||
if (newValue.endsWith(".") || newValue.endsWith(".0")) {
|
||||
setTransitoryDisplayValue(newValue);
|
||||
} else {
|
||||
setTransitoryDisplayValue(undefined);
|
||||
}
|
||||
|
||||
const newEthValue = displayValueToEtherValue(displayUsdMode, newValue, nativeCurrencyPrice || 0);
|
||||
onChange(newEthValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<InputBase
|
||||
name={name}
|
||||
value={displayValue}
|
||||
placeholder={placeholder}
|
||||
onChange={handleChangeNumber}
|
||||
disabled={disabled}
|
||||
prefix={<span className="pl-4 -mr-2 text-accent self-center">{displayUsdMode ? "$" : "Ξ"}</span>}
|
||||
suffix={
|
||||
<div
|
||||
className={`${
|
||||
nativeCurrencyPrice > 0
|
||||
? ""
|
||||
: "tooltip tooltip-secondary before:content-[attr(data-tip)] before:right-[-10px] before:left-auto before:transform-none"
|
||||
}`}
|
||||
data-tip={isNativeCurrencyPriceFetching ? "Fetching price" : "Unable to fetch price"}
|
||||
>
|
||||
<button
|
||||
className="btn btn-primary h-[2.2rem] min-h-[2.2rem]"
|
||||
onClick={toggleDisplayUsdMode}
|
||||
disabled={!displayUsdMode && !nativeCurrencyPrice}
|
||||
type="button"
|
||||
>
|
||||
<ArrowsRightLeftIcon className="h-3 w-3 cursor-pointer" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
66
packages/nextjs/components/scaffold-eth/Input/InputBase.tsx
Normal file
66
packages/nextjs/components/scaffold-eth/Input/InputBase.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { ChangeEvent, FocusEvent, ReactNode, useCallback, useEffect, useRef } from "react";
|
||||
import { CommonInputProps } from "~~/components/scaffold-eth";
|
||||
|
||||
type InputBaseProps<T> = CommonInputProps<T> & {
|
||||
error?: boolean;
|
||||
prefix?: ReactNode;
|
||||
suffix?: ReactNode;
|
||||
reFocus?: boolean;
|
||||
};
|
||||
|
||||
export const InputBase = <T extends { toString: () => string } | undefined = string>({
|
||||
name,
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
error,
|
||||
disabled,
|
||||
prefix,
|
||||
suffix,
|
||||
reFocus,
|
||||
}: InputBaseProps<T>) => {
|
||||
const inputReft = useRef<HTMLInputElement>(null);
|
||||
|
||||
let modifier = "";
|
||||
if (error) {
|
||||
modifier = "border-error";
|
||||
} else if (disabled) {
|
||||
modifier = "border-disabled bg-base-300";
|
||||
}
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
onChange(e.target.value as unknown as T);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
// Runs only when reFocus prop is passed, useful for setting the cursor
|
||||
// at the end of the input. Example AddressInput
|
||||
const onFocus = (e: FocusEvent<HTMLInputElement, Element>) => {
|
||||
if (reFocus !== undefined) {
|
||||
e.currentTarget.setSelectionRange(e.currentTarget.value.length, e.currentTarget.value.length);
|
||||
}
|
||||
};
|
||||
useEffect(() => {
|
||||
if (reFocus !== undefined && reFocus === true) inputReft.current?.focus();
|
||||
}, [reFocus]);
|
||||
|
||||
return (
|
||||
<div className={`flex border-2 border-base-300 bg-base-200 rounded-full text-accent ${modifier}`}>
|
||||
{prefix}
|
||||
<input
|
||||
className="input input-ghost focus-within:border-transparent focus:outline-hidden focus:bg-transparent h-[2.2rem] min-h-[2.2rem] px-4 border w-full font-medium placeholder:text-accent/70 text-base-content/70 focus:text-base-content/70"
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
value={value?.toString()}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
autoComplete="off"
|
||||
ref={inputReft}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
{suffix}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { parseEther } from "viem";
|
||||
import { CommonInputProps, InputBase, IntegerVariant, isValidInteger } from "~~/components/scaffold-eth";
|
||||
|
||||
type IntegerInputProps = CommonInputProps<string> & {
|
||||
variant?: IntegerVariant;
|
||||
disableMultiplyBy1e18?: boolean;
|
||||
};
|
||||
|
||||
export const IntegerInput = ({
|
||||
value,
|
||||
onChange,
|
||||
name,
|
||||
placeholder,
|
||||
disabled,
|
||||
variant = IntegerVariant.UINT256,
|
||||
disableMultiplyBy1e18 = false,
|
||||
}: IntegerInputProps) => {
|
||||
const [inputError, setInputError] = useState(false);
|
||||
const multiplyBy1e18 = useCallback(() => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
return onChange(parseEther(value).toString());
|
||||
}, [onChange, value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isValidInteger(variant, value)) {
|
||||
setInputError(false);
|
||||
} else {
|
||||
setInputError(true);
|
||||
}
|
||||
}, [value, variant]);
|
||||
|
||||
return (
|
||||
<InputBase
|
||||
name={name}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
error={inputError}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
suffix={
|
||||
!inputError &&
|
||||
!disableMultiplyBy1e18 && (
|
||||
<div
|
||||
className="space-x-4 flex tooltip tooltip-top tooltip-secondary before:content-[attr(data-tip)] before:right-[-10px] before:left-auto before:transform-none"
|
||||
data-tip="Multiply by 1e18 (wei)"
|
||||
>
|
||||
<button
|
||||
className={`${disabled ? "cursor-not-allowed" : "cursor-pointer"} font-semibold px-4 text-accent`}
|
||||
onClick={multiplyBy1e18}
|
||||
disabled={disabled}
|
||||
type="button"
|
||||
>
|
||||
∗
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
9
packages/nextjs/components/scaffold-eth/Input/index.ts
Normal file
9
packages/nextjs/components/scaffold-eth/Input/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
"use client";
|
||||
|
||||
export * from "./AddressInput";
|
||||
export * from "./Bytes32Input";
|
||||
export * from "./BytesInput";
|
||||
export * from "./EtherInput";
|
||||
export * from "./InputBase";
|
||||
export * from "./IntegerInput";
|
||||
export * from "./utils";
|
||||
109
packages/nextjs/components/scaffold-eth/Input/utils.ts
Normal file
109
packages/nextjs/components/scaffold-eth/Input/utils.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export type CommonInputProps<T = string> = {
|
||||
value: T;
|
||||
onChange: (newValue: T) => void;
|
||||
name?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export enum IntegerVariant {
|
||||
UINT8 = "uint8",
|
||||
UINT16 = "uint16",
|
||||
UINT24 = "uint24",
|
||||
UINT32 = "uint32",
|
||||
UINT40 = "uint40",
|
||||
UINT48 = "uint48",
|
||||
UINT56 = "uint56",
|
||||
UINT64 = "uint64",
|
||||
UINT72 = "uint72",
|
||||
UINT80 = "uint80",
|
||||
UINT88 = "uint88",
|
||||
UINT96 = "uint96",
|
||||
UINT104 = "uint104",
|
||||
UINT112 = "uint112",
|
||||
UINT120 = "uint120",
|
||||
UINT128 = "uint128",
|
||||
UINT136 = "uint136",
|
||||
UINT144 = "uint144",
|
||||
UINT152 = "uint152",
|
||||
UINT160 = "uint160",
|
||||
UINT168 = "uint168",
|
||||
UINT176 = "uint176",
|
||||
UINT184 = "uint184",
|
||||
UINT192 = "uint192",
|
||||
UINT200 = "uint200",
|
||||
UINT208 = "uint208",
|
||||
UINT216 = "uint216",
|
||||
UINT224 = "uint224",
|
||||
UINT232 = "uint232",
|
||||
UINT240 = "uint240",
|
||||
UINT248 = "uint248",
|
||||
UINT256 = "uint256",
|
||||
INT8 = "int8",
|
||||
INT16 = "int16",
|
||||
INT24 = "int24",
|
||||
INT32 = "int32",
|
||||
INT40 = "int40",
|
||||
INT48 = "int48",
|
||||
INT56 = "int56",
|
||||
INT64 = "int64",
|
||||
INT72 = "int72",
|
||||
INT80 = "int80",
|
||||
INT88 = "int88",
|
||||
INT96 = "int96",
|
||||
INT104 = "int104",
|
||||
INT112 = "int112",
|
||||
INT120 = "int120",
|
||||
INT128 = "int128",
|
||||
INT136 = "int136",
|
||||
INT144 = "int144",
|
||||
INT152 = "int152",
|
||||
INT160 = "int160",
|
||||
INT168 = "int168",
|
||||
INT176 = "int176",
|
||||
INT184 = "int184",
|
||||
INT192 = "int192",
|
||||
INT200 = "int200",
|
||||
INT208 = "int208",
|
||||
INT216 = "int216",
|
||||
INT224 = "int224",
|
||||
INT232 = "int232",
|
||||
INT240 = "int240",
|
||||
INT248 = "int248",
|
||||
INT256 = "int256",
|
||||
}
|
||||
|
||||
export const SIGNED_NUMBER_REGEX = /^-?\d+\.?\d*$/;
|
||||
export const UNSIGNED_NUMBER_REGEX = /^\.?\d+\.?\d*$/;
|
||||
|
||||
export const isValidInteger = (dataType: IntegerVariant, value: string) => {
|
||||
const isSigned = dataType.startsWith("i");
|
||||
const bitcount = Number(dataType.substring(isSigned ? 3 : 4));
|
||||
|
||||
let valueAsBigInt;
|
||||
try {
|
||||
valueAsBigInt = BigInt(value);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {}
|
||||
if (typeof valueAsBigInt !== "bigint") {
|
||||
if (!value || typeof value !== "string") {
|
||||
return true;
|
||||
}
|
||||
return isSigned ? SIGNED_NUMBER_REGEX.test(value) || value === "-" : UNSIGNED_NUMBER_REGEX.test(value);
|
||||
} else if (!isSigned && valueAsBigInt < 0) {
|
||||
return false;
|
||||
}
|
||||
const hexString = valueAsBigInt.toString(16);
|
||||
const significantHexDigits = hexString.match(/.*x0*(.*)$/)?.[1] ?? "";
|
||||
if (
|
||||
significantHexDigits.length * 4 > bitcount ||
|
||||
(isSigned && significantHexDigits.length * 4 === bitcount && parseInt(significantHexDigits.slice(-1)?.[0], 16) < 8)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// Treat any dot-separated string as a potential ENS name
|
||||
const ensRegex = /.+\..+/;
|
||||
export const isENS = (address = "") => ensRegex.test(address);
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { NetworkOptions } from "./NetworkOptions";
|
||||
import { getAddress } from "viem";
|
||||
import { Address } from "viem";
|
||||
import { useAccount, useDisconnect } from "wagmi";
|
||||
import {
|
||||
ArrowLeftOnRectangleIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronDownIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EyeIcon,
|
||||
QrCodeIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { BlockieAvatar, isENS } from "~~/components/scaffold-eth";
|
||||
import { useCopyToClipboard, useOutsideClick } from "~~/hooks/scaffold-eth";
|
||||
import { getTargetNetworks } from "~~/utils/scaffold-eth";
|
||||
|
||||
const BURNER_WALLET_ID = "burnerWallet";
|
||||
|
||||
const allowedNetworks = getTargetNetworks();
|
||||
|
||||
type AddressInfoDropdownProps = {
|
||||
address: Address;
|
||||
blockExplorerAddressLink: string | undefined;
|
||||
displayName: string;
|
||||
ensAvatar?: string;
|
||||
};
|
||||
|
||||
export const AddressInfoDropdown = ({
|
||||
address,
|
||||
ensAvatar,
|
||||
displayName,
|
||||
blockExplorerAddressLink,
|
||||
}: AddressInfoDropdownProps) => {
|
||||
const { disconnect } = useDisconnect();
|
||||
const { connector } = useAccount();
|
||||
const checkSumAddress = getAddress(address);
|
||||
|
||||
const { copyToClipboard: copyAddressToClipboard, isCopiedToClipboard: isAddressCopiedToClipboard } =
|
||||
useCopyToClipboard();
|
||||
const [selectingNetwork, setSelectingNetwork] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDetailsElement>(null);
|
||||
|
||||
const closeDropdown = () => {
|
||||
setSelectingNetwork(false);
|
||||
dropdownRef.current?.removeAttribute("open");
|
||||
};
|
||||
|
||||
useOutsideClick(dropdownRef, closeDropdown);
|
||||
|
||||
return (
|
||||
<>
|
||||
<details ref={dropdownRef} className="dropdown dropdown-end leading-3">
|
||||
<summary className="btn btn-secondary btn-sm pl-0 pr-2 shadow-md dropdown-toggle gap-0 h-auto!">
|
||||
<BlockieAvatar address={checkSumAddress} size={30} ensImage={ensAvatar} />
|
||||
<span className="ml-2 mr-1">
|
||||
{isENS(displayName) ? displayName : checkSumAddress?.slice(0, 6) + "..." + checkSumAddress?.slice(-4)}
|
||||
</span>
|
||||
<ChevronDownIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
</summary>
|
||||
<ul className="dropdown-content menu z-2 p-2 mt-2 shadow-center shadow-accent bg-base-200 rounded-box gap-1">
|
||||
<NetworkOptions hidden={!selectingNetwork} />
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<div
|
||||
className="h-8 btn-sm rounded-xl! flex gap-3 py-3 cursor-pointer"
|
||||
onClick={() => copyAddressToClipboard(checkSumAddress)}
|
||||
>
|
||||
{isAddressCopiedToClipboard ? (
|
||||
<>
|
||||
<CheckCircleIcon className="text-xl font-normal h-6 w-4 ml-2 sm:ml-0" aria-hidden="true" />
|
||||
<span className="whitespace-nowrap">Copied!</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DocumentDuplicateIcon className="text-xl font-normal h-6 w-4 ml-2 sm:ml-0" aria-hidden="true" />
|
||||
<span className="whitespace-nowrap">Copy address</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<label htmlFor="qrcode-modal" className="h-8 btn-sm rounded-xl! flex gap-3 py-3">
|
||||
<QrCodeIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<span className="whitespace-nowrap">View QR Code</span>
|
||||
</label>
|
||||
</li>
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<button className="h-8 btn-sm rounded-xl! flex gap-3 py-3" type="button">
|
||||
<ArrowTopRightOnSquareIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<a
|
||||
target="_blank"
|
||||
href={blockExplorerAddressLink}
|
||||
rel="noopener noreferrer"
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
View on Block Explorer
|
||||
</a>
|
||||
</button>
|
||||
</li>
|
||||
{allowedNetworks.length > 1 ? (
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<button
|
||||
className="h-8 btn-sm rounded-xl! flex gap-3 py-3"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSelectingNetwork(true);
|
||||
}}
|
||||
>
|
||||
<ArrowsRightLeftIcon className="h-6 w-4 ml-2 sm:ml-0" /> <span>Switch Network</span>
|
||||
</button>
|
||||
</li>
|
||||
) : null}
|
||||
{connector?.id === BURNER_WALLET_ID ? (
|
||||
<li>
|
||||
<label htmlFor="reveal-burner-pk-modal" className="h-8 btn-sm rounded-xl! flex gap-3 py-3 text-error">
|
||||
<EyeIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<span>Reveal Private Key</span>
|
||||
</label>
|
||||
</li>
|
||||
) : null}
|
||||
<li className={selectingNetwork ? "hidden" : ""}>
|
||||
<button
|
||||
className="menu-item text-error h-8 btn-sm rounded-xl! flex gap-3 py-3"
|
||||
type="button"
|
||||
onClick={() => disconnect()}
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon className="h-6 w-4 ml-2 sm:ml-0" /> <span>Disconnect</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import { QRCodeSVG } from "qrcode.react";
|
||||
import { Address as AddressType } from "viem";
|
||||
import { Address } from "~~/components/scaffold-eth";
|
||||
|
||||
type AddressQRCodeModalProps = {
|
||||
address: AddressType;
|
||||
modalId: string;
|
||||
};
|
||||
|
||||
export const AddressQRCodeModal = ({ address, modalId }: AddressQRCodeModalProps) => {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<input type="checkbox" id={`${modalId}`} className="modal-toggle" />
|
||||
<label htmlFor={`${modalId}`} className="modal cursor-pointer">
|
||||
<label className="modal-box relative">
|
||||
{/* dummy input to capture event onclick on modal box */}
|
||||
<input className="h-0 w-0 absolute top-0 left-0" />
|
||||
<label htmlFor={`${modalId}`} className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
|
||||
✕
|
||||
</label>
|
||||
<div className="space-y-3 py-6">
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<QRCodeSVG value={address} size={256} />
|
||||
<Address address={address} format="long" disableAddressLink onlyEnsOrAddress />
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { useAccount, useSwitchChain } from "wagmi";
|
||||
import { ArrowsRightLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { getNetworkColor } from "~~/hooks/scaffold-eth";
|
||||
import { getTargetNetworks } from "~~/utils/scaffold-eth";
|
||||
|
||||
const allowedNetworks = getTargetNetworks();
|
||||
|
||||
type NetworkOptionsProps = {
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export const NetworkOptions = ({ hidden = false }: NetworkOptionsProps) => {
|
||||
const { switchChain } = useSwitchChain();
|
||||
const { chain } = useAccount();
|
||||
const { resolvedTheme } = useTheme();
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<>
|
||||
{allowedNetworks
|
||||
.filter(allowedNetwork => allowedNetwork.id !== chain?.id)
|
||||
.map(allowedNetwork => (
|
||||
<li key={allowedNetwork.id} className={hidden ? "hidden" : ""}>
|
||||
<button
|
||||
className="menu-item btn-sm rounded-xl! flex gap-3 py-3 whitespace-nowrap"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
switchChain?.({ chainId: allowedNetwork.id });
|
||||
}}
|
||||
>
|
||||
<ArrowsRightLeftIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<span>
|
||||
Switch to{" "}
|
||||
<span
|
||||
style={{
|
||||
color: getNetworkColor(allowedNetwork, isDarkMode),
|
||||
}}
|
||||
>
|
||||
{allowedNetwork.name}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useRef } from "react";
|
||||
import { rainbowkitBurnerWallet } from "burner-connector";
|
||||
import { ShieldExclamationIcon } from "@heroicons/react/24/outline";
|
||||
import { useCopyToClipboard } from "~~/hooks/scaffold-eth";
|
||||
import { getParsedError, notification } from "~~/utils/scaffold-eth";
|
||||
|
||||
const BURNER_WALLET_PK_KEY = "burnerWallet.pk";
|
||||
|
||||
export const RevealBurnerPKModal = () => {
|
||||
const { copyToClipboard, isCopiedToClipboard } = useCopyToClipboard();
|
||||
const modalCheckboxRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleCopyPK = async () => {
|
||||
try {
|
||||
const storage = rainbowkitBurnerWallet.useSessionStorage ? sessionStorage : localStorage;
|
||||
const burnerPK = storage?.getItem(BURNER_WALLET_PK_KEY);
|
||||
if (!burnerPK) throw new Error("Burner wallet private key not found");
|
||||
await copyToClipboard(burnerPK);
|
||||
notification.success("Burner wallet private key copied to clipboard");
|
||||
} catch (e) {
|
||||
const parsedError = getParsedError(e);
|
||||
notification.error(parsedError);
|
||||
if (modalCheckboxRef.current) modalCheckboxRef.current.checked = false;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<input type="checkbox" id="reveal-burner-pk-modal" className="modal-toggle" ref={modalCheckboxRef} />
|
||||
<label htmlFor="reveal-burner-pk-modal" className="modal cursor-pointer">
|
||||
<label className="modal-box relative">
|
||||
{/* dummy input to capture event onclick on modal box */}
|
||||
<input className="h-0 w-0 absolute top-0 left-0" />
|
||||
<label htmlFor="reveal-burner-pk-modal" className="btn btn-ghost btn-sm btn-circle absolute right-3 top-3">
|
||||
✕
|
||||
</label>
|
||||
<div>
|
||||
<p className="text-lg font-semibold m-0 p-0">Copy Burner Wallet Private Key</p>
|
||||
<div role="alert" className="alert alert-warning mt-4">
|
||||
<ShieldExclamationIcon className="h-6 w-6" />
|
||||
<span className="font-semibold">
|
||||
Burner wallets are intended for local development only and are not safe for storing real funds.
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
Your Private Key provides <strong>full access</strong> to your entire wallet and funds. This is
|
||||
currently stored <strong>temporarily</strong> in your browser.
|
||||
</p>
|
||||
<button className="btn btn-outline btn-error" onClick={handleCopyPK} disabled={isCopiedToClipboard}>
|
||||
Copy Private Key To Clipboard
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
</label>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NetworkOptions } from "./NetworkOptions";
|
||||
import { useDisconnect } from "wagmi";
|
||||
import { ArrowLeftOnRectangleIcon, ChevronDownIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export const WrongNetworkDropdown = () => {
|
||||
const { disconnect } = useDisconnect();
|
||||
|
||||
return (
|
||||
<div className="dropdown dropdown-end mr-2">
|
||||
<label tabIndex={0} className="btn btn-error btn-sm dropdown-toggle gap-1">
|
||||
<span>Wrong network</span>
|
||||
<ChevronDownIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
</label>
|
||||
<ul
|
||||
tabIndex={0}
|
||||
className="dropdown-content menu p-2 mt-1 shadow-center shadow-accent bg-base-200 rounded-box gap-1"
|
||||
>
|
||||
<NetworkOptions />
|
||||
<li>
|
||||
<button
|
||||
className="menu-item text-error btn-sm rounded-xl! flex gap-3 py-3"
|
||||
type="button"
|
||||
onClick={() => disconnect()}
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon className="h-6 w-4 ml-2 sm:ml-0" />
|
||||
<span>Disconnect</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
};
|
||||
7
packages/nextjs/components/scaffold-eth/index.tsx
Normal file
7
packages/nextjs/components/scaffold-eth/index.tsx
Normal 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";
|
||||
9
packages/nextjs/contracts/deployedContracts.ts
Normal file
9
packages/nextjs/contracts/deployedContracts.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* This file is autogenerated by Scaffold-ETH.
|
||||
* You should not edit it manually or your changes might be overwritten.
|
||||
*/
|
||||
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
const deployedContracts = {} as const;
|
||||
|
||||
export default deployedContracts satisfies GenericContractsDeclaration;
|
||||
16
packages/nextjs/contracts/externalContracts.ts
Normal file
16
packages/nextjs/contracts/externalContracts.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { GenericContractsDeclaration } from "~~/utils/scaffold-eth/contract";
|
||||
|
||||
/**
|
||||
* @example
|
||||
* const externalContracts = {
|
||||
* 1: {
|
||||
* DAI: {
|
||||
* address: "0x...",
|
||||
* abi: [...],
|
||||
* },
|
||||
* },
|
||||
* } as const;
|
||||
*/
|
||||
const externalContracts = {} as const;
|
||||
|
||||
export default externalContracts satisfies GenericContractsDeclaration;
|
||||
32
packages/nextjs/eslint.config.mjs
Normal file
32
packages/nextjs/eslint.config.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import prettierPlugin from "eslint-plugin-prettier";
|
||||
import { defineConfig } from "eslint/config";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
export default defineConfig([
|
||||
{
|
||||
plugins: {
|
||||
prettier: prettierPlugin,
|
||||
},
|
||||
extends: compat.extends("next/core-web-vitals", "next/typescript", "prettier"),
|
||||
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/ban-ts-comment": "off",
|
||||
|
||||
"prettier/prettier": [
|
||||
"warn",
|
||||
{
|
||||
endOfLine: "auto",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
17
packages/nextjs/hooks/scaffold-eth/index.ts
Normal file
17
packages/nextjs/hooks/scaffold-eth/index.ts
Normal 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";
|
||||
20
packages/nextjs/hooks/scaffold-eth/useAnimationConfig.ts
Normal file
20
packages/nextjs/hooks/scaffold-eth/useAnimationConfig.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
40
packages/nextjs/hooks/scaffold-eth/useContractLogs.ts
Normal file
40
packages/nextjs/hooks/scaffold-eth/useContractLogs.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTargetNetwork } from "./useTargetNetwork";
|
||||
import { Address, Log } from "viem";
|
||||
import { usePublicClient } from "wagmi";
|
||||
|
||||
export const useContractLogs = (address: Address) => {
|
||||
const [logs, setLogs] = useState<Log[]>([]);
|
||||
const { targetNetwork } = useTargetNetwork();
|
||||
const client = usePublicClient({ chainId: targetNetwork.id });
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLogs = async () => {
|
||||
if (!client) return console.error("Client not found");
|
||||
try {
|
||||
const existingLogs = await client.getLogs({
|
||||
address: address,
|
||||
fromBlock: 0n,
|
||||
toBlock: "latest",
|
||||
});
|
||||
setLogs(existingLogs);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch logs:", error);
|
||||
}
|
||||
};
|
||||
fetchLogs();
|
||||
|
||||
return client?.watchBlockNumber({
|
||||
onBlockNumber: async (_blockNumber, prevBlockNumber) => {
|
||||
const newLogs = await client.getLogs({
|
||||
address: address,
|
||||
fromBlock: prevBlockNumber,
|
||||
toBlock: "latest",
|
||||
});
|
||||
setLogs(prevLogs => [...prevLogs, ...newLogs]);
|
||||
},
|
||||
});
|
||||
}, [address, client]);
|
||||
|
||||
return logs;
|
||||
};
|
||||
19
packages/nextjs/hooks/scaffold-eth/useCopyToClipboard.ts
Normal file
19
packages/nextjs/hooks/scaffold-eth/useCopyToClipboard.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { useState } from "react";
|
||||
|
||||
export const useCopyToClipboard = () => {
|
||||
const [isCopiedToClipboard, setIsCopiedToClipboard] = useState(false);
|
||||
|
||||
const copyToClipboard = async (text: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setIsCopiedToClipboard(true);
|
||||
setTimeout(() => {
|
||||
setIsCopiedToClipboard(false);
|
||||
}, 800);
|
||||
} catch (err) {
|
||||
console.error("Failed to copy text:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return { copyToClipboard, isCopiedToClipboard };
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user