diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8c1c21c --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# Blockchain Configuration +PRIVATE_KEY=your_private_key_here +BASE_RPC_URL=https://mainnet.base.org +BASE_SEPOLIA_RPC_URL=https://sepolia.base.org +BASESCAN_API_KEY=your_basescan_api_key_here + +# Contract Addresses (update after deployment) +NEXT_PUBLIC_CONTRACT_ADDRESS= +NEXT_PUBLIC_CHAIN_ID=8453 + +# API Keys +NEXT_PUBLIC_ALCHEMY_API_KEY=your_alchemy_api_key_here +NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID=your_walletconnect_project_id_here + +# Optional +COINMARKETCAP_API_KEY= +REPORT_GAS=false + +# Development +NODE_ENV=development diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5158169 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,150 @@ +name: CI/CD Pipeline + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +jobs: + lint: + name: Lint Code + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run ESLint + run: npm run lint + + test-contracts: + name: Test Smart Contracts + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Compile contracts + run: npm run compile + + - name: Run contract tests + run: npm test + + - name: Generate coverage report + run: npm run test:coverage + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + files: ./coverage/lcov.info + flags: smart-contracts + name: contract-coverage + + build-frontend: + name: Build Frontend + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Build Next.js app + run: npm run build + env: + NEXT_PUBLIC_CONTRACT_ADDRESS: "0x0000000000000000000000000000000000000000" + NEXT_PUBLIC_CHAIN_ID: "84532" + + security-audit: + name: Security Audit + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Run npm audit + run: npm audit --audit-level=moderate + continue-on-error: true + + - name: Install Slither + run: | + pip3 install slither-analyzer + pip3 install solc-select + solc-select install 0.8.19 + solc-select use 0.8.19 + + - name: Run Slither + run: slither contracts/ + continue-on-error: true + + gas-report: + name: Gas Report + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Generate gas report + run: REPORT_GAS=true npm test + env: + COINMARKETCAP_API_KEY: ${{ secrets.COINMARKETCAP_API_KEY }} + + - name: Comment gas report + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const gasReport = fs.readFileSync('gas-report.txt', 'utf8'); + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## Gas Report\n\`\`\`\n${gasReport}\n\`\`\`` + }); + continue-on-error: true diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f416ec8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Comprehensive smart contract test suite with 100% coverage target +- Hardhat development environment configuration +- Deployment scripts for local, testnet, and mainnet +- CI/CD pipeline with GitHub Actions +- Security audit workflows +- Gas reporting in pull requests +- Environment configuration templates +- Contributing guidelines +- Security policy documentation +- MIT License + +### Changed +- Enhanced BaseRegistry contract with OpenZeppelin security features +- Added Ownable, ReentrancyGuard, and Pausable patterns +- Implemented transfer functionality for name ownership +- Added fee management and withdrawal mechanisms +- Updated README with comprehensive documentation +- Improved package.json with Hardhat scripts + +### Security +- Added reentrancy protection to critical functions +- Implemented emergency pause mechanism +- Added input validation and length limits +- Integrated OpenZeppelin battle-tested contracts +- Added access control for admin functions + +## [0.1.0] - 2026-01-18 + +### Added +- Initial release +- Basic BaseRegistry smart contract +- Name registration functionality +- Data update capability +- Next.js frontend with Tailwind CSS +- shadcn/ui component library integration +- Dark/light theme support + +### Features +- Register unique names on Base blockchain +- Associate data with registered names +- Update data for owned names +- Query name availability +- View record details + +[Unreleased]: https://github.com/yourusername/baseregistry/compare/v0.1.0...HEAD +[0.1.0]: https://github.com/yourusername/baseregistry/releases/tag/v0.1.0 diff --git a/contracts/BaseRegistry.sol b/contracts/BaseRegistry.sol index c6f6c20..070cd55 100644 --- a/contracts/BaseRegistry.sol +++ b/contracts/BaseRegistry.sol @@ -1,12 +1,24 @@ // SPDX-License-Identifier: MIT pragma solidity ^0.8.19; +import "@openzeppelin/contracts/access/Ownable.sol"; +import "@openzeppelin/contracts/utils/ReentrancyGuard.sol"; +import "@openzeppelin/contracts/utils/Pausable.sol"; + /** * @title BaseRegistry - * @dev A simple registry contract for the Base ecosystem. - * Users can register a unique name and associate string data with it. + * @dev A secure registry contract for the Base ecosystem. + * Users can register unique names and associate string data with them. + * Includes ownership transfer, access control, and emergency pause functionality. */ -contract BaseRegistry { +contract BaseRegistry is Ownable, ReentrancyGuard, Pausable { + // Constants + uint256 public constant MAX_NAME_LENGTH = 32; + uint256 public constant MAX_DATA_LENGTH = 256; + + // State variables + uint256 public registrationFee; + struct Record { address owner; string data; @@ -16,25 +28,68 @@ contract BaseRegistry { // Mapping from name to Record mapping(string => Record) private _registry; + + // Mapping to track names owned by each address + mapping(address => string[]) private _ownedNames; - // Event emitted when a new name is registered - event Registered(string indexed name, address indexed owner, string data); + // Events + event Registered( + string indexed name, + address indexed owner, + string data, + uint256 timestamp + ); + + event Updated( + string indexed name, + address indexed owner, + string data, + uint256 timestamp + ); + + event Transferred( + string indexed name, + address indexed from, + address indexed to, + uint256 timestamp + ); - // Event emitted when data is updated - event Updated(string indexed name, address indexed owner, string data); + event FeeUpdated(uint256 oldFee, uint256 newFee); + + event FundsWithdrawn(address indexed to, uint256 amount); + + /** + * @dev Constructor sets the initial owner and registration fee + * @param _initialFee Initial registration fee in wei + */ + constructor(uint256 _initialFee) Ownable(msg.sender) { + registrationFee = _initialFee; + } /** * @dev Register a new name with data. * @param name The unique name to register. * @param data The data to associate with the name. - + * * Requirements: - * - Name must not be empty. - * - Name must not be already registered. + * - Contract must not be paused + * - Name must not be empty + * - Name length must not exceed MAX_NAME_LENGTH + * - Data length must not exceed MAX_DATA_LENGTH + * - Name must not be already registered + * - Sufficient fee must be paid */ - function register(string calldata name, string calldata data) external { + function register(string calldata name, string calldata data) + external + payable + whenNotPaused + nonReentrant + { require(bytes(name).length > 0, "Name cannot be empty"); + require(bytes(name).length <= MAX_NAME_LENGTH, "Name too long"); + require(bytes(data).length <= MAX_DATA_LENGTH, "Data too long"); require(_registry[name].owner == address(0), "Name already registered"); + require(msg.value >= registrationFee, "Insufficient fee"); _registry[name] = Record({ owner: msg.sender, @@ -42,8 +97,16 @@ contract BaseRegistry { createdAt: block.timestamp, updatedAt: block.timestamp }); + + _ownedNames[msg.sender].push(name); - emit Registered(name, msg.sender, data); + emit Registered(name, msg.sender, data, block.timestamp); + + // Refund excess payment + if (msg.value > registrationFee) { + (bool success, ) = msg.sender.call{value: msg.value - registrationFee}(""); + require(success, "Refund failed"); + } } /** @@ -52,30 +115,132 @@ contract BaseRegistry { * @param data The new data. * * Requirements: - * - Caller must be the owner of the name. + * - Contract must not be paused + * - Caller must be the owner of the name + * - Data length must not exceed MAX_DATA_LENGTH */ - function update(string calldata name, string calldata data) external { + function update(string calldata name, string calldata data) + external + whenNotPaused + { require(_registry[name].owner == msg.sender, "Not the owner"); + require(bytes(data).length <= MAX_DATA_LENGTH, "Data too long"); _registry[name].data = data; _registry[name].updatedAt = block.timestamp; - emit Updated(name, msg.sender, data); + emit Updated(name, msg.sender, data, block.timestamp); + } + + /** + * @dev Transfer ownership of a name to another address. + * @param name The name to transfer. + * @param newOwner The address of the new owner. + * + * Requirements: + * - Contract must not be paused + * - Caller must be the current owner + * - New owner must not be zero address + */ + function transfer(string calldata name, address newOwner) + external + whenNotPaused + { + require(_registry[name].owner == msg.sender, "Not the owner"); + require(newOwner != address(0), "Invalid new owner"); + require(newOwner != msg.sender, "Already the owner"); + + address previousOwner = msg.sender; + _registry[name].owner = newOwner; + _registry[name].updatedAt = block.timestamp; + + // Update ownership tracking + _ownedNames[newOwner].push(name); + + emit Transferred(name, previousOwner, newOwner, block.timestamp); } /** * @dev Get record details. * @param name The name to query. + * @return owner The owner address + * @return data The associated data + * @return createdAt Creation timestamp + * @return updatedAt Last update timestamp */ - function getRecord(string calldata name) external view returns (address owner, string memory data, uint256 createdAt, uint256 updatedAt) { + function getRecord(string calldata name) + external + view + returns ( + address owner, + string memory data, + uint256 createdAt, + uint256 updatedAt + ) + { Record memory record = _registry[name]; return (record.owner, record.data, record.createdAt, record.updatedAt); } /** - * @dev Check if a name is validated/available. + * @dev Check if a name is available for registration. + * @param name The name to check. + * @return bool True if available, false otherwise */ function isAvailable(string calldata name) external view returns (bool) { return _registry[name].owner == address(0); } + + /** + * @dev Get all names owned by an address. + * @param owner The address to query. + * @return string[] Array of owned names + */ + function getOwnedNames(address owner) external view returns (string[] memory) { + return _ownedNames[owner]; + } + + /** + * @dev Update the registration fee. Only callable by owner. + * @param newFee The new registration fee in wei. + */ + function setRegistrationFee(uint256 newFee) external onlyOwner { + uint256 oldFee = registrationFee; + registrationFee = newFee; + emit FeeUpdated(oldFee, newFee); + } + + /** + * @dev Pause the contract. Only callable by owner. + */ + function pause() external onlyOwner { + _pause(); + } + + /** + * @dev Unpause the contract. Only callable by owner. + */ + function unpause() external onlyOwner { + _unpause(); + } + + /** + * @dev Withdraw accumulated fees. Only callable by owner. + * @param to The address to send funds to. + */ + function withdraw(address payable to) external onlyOwner nonReentrant { + require(to != address(0), "Invalid address"); + uint256 balance = address(this).balance; + require(balance > 0, "No funds to withdraw"); + + (bool success, ) = to.call{value: balance}(""); + require(success, "Withdrawal failed"); + + emit FundsWithdrawn(to, balance); + } + + /** + * @dev Receive function to accept ETH + */ + receive() external payable {} } diff --git a/hardhat.config.ts b/hardhat.config.ts new file mode 100644 index 0000000..3886638 --- /dev/null +++ b/hardhat.config.ts @@ -0,0 +1,79 @@ +import { HardhatUserConfig } from "hardhat/config"; +import "@nomicfoundation/hardhat-toolbox"; +import "@nomicfoundation/hardhat-verify"; +import "@typechain/hardhat"; +import * as dotenv from "dotenv"; + +dotenv.config(); + +const config: HardhatUserConfig = { + solidity: { + version: "0.8.19", + settings: { + optimizer: { + enabled: true, + runs: 200, + }, + viaIR: true, + }, + }, + networks: { + hardhat: { + chainId: 31337, + }, + base: { + url: process.env.BASE_RPC_URL || "https://mainnet.base.org", + accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + chainId: 8453, + }, + baseSepolia: { + url: process.env.BASE_SEPOLIA_RPC_URL || "https://sepolia.base.org", + accounts: process.env.PRIVATE_KEY ? [process.env.PRIVATE_KEY] : [], + chainId: 84532, + }, + localhost: { + url: "http://127.0.0.1:8545", + }, + }, + etherscan: { + apiKey: { + base: process.env.BASESCAN_API_KEY || "", + baseSepolia: process.env.BASESCAN_API_KEY || "", + }, + customChains: [ + { + network: "base", + chainId: 8453, + urls: { + apiURL: "https://api.basescan.org/api", + browserURL: "https://basescan.org", + }, + }, + { + network: "baseSepolia", + chainId: 84532, + urls: { + apiURL: "https://api-sepolia.basescan.org/api", + browserURL: "https://sepolia.basescan.org", + }, + }, + ], + }, + paths: { + sources: "./contracts", + tests: "./test", + cache: "./cache", + artifacts: "./artifacts", + }, + typechain: { + outDir: "typechain-types", + target: "ethers-v6", + }, + gasReporter: { + enabled: process.env.REPORT_GAS === "true", + currency: "USD", + coinmarketcap: process.env.COINMARKETCAP_API_KEY, + }, +}; + +export default config; diff --git a/scripts/deploy.ts b/scripts/deploy.ts new file mode 100644 index 0000000..57e999b --- /dev/null +++ b/scripts/deploy.ts @@ -0,0 +1,57 @@ +import { ethers, run, network } from "hardhat"; + +async function main() { + console.log("Deploying BaseRegistry to", network.name); + + // Configuration + const REGISTRATION_FEE = ethers.parseEther("0.001"); // 0.001 ETH + + console.log("Registration fee:", ethers.formatEther(REGISTRATION_FEE), "ETH"); + + // Deploy + const BaseRegistry = await ethers.getContractFactory("BaseRegistry"); + const baseRegistry = await BaseRegistry.deploy(REGISTRATION_FEE); + + await baseRegistry.waitForDeployment(); + + const address = await baseRegistry.getAddress(); + console.log("BaseRegistry deployed to:", address); + + // Wait for block confirmations before verification + if (network.name !== "hardhat" && network.name !== "localhost") { + console.log("Waiting for block confirmations..."); + await baseRegistry.deploymentTransaction()?.wait(6); + + // Verify contract on Basescan + console.log("Verifying contract on Basescan..."); + try { + await run("verify:verify", { + address: address, + constructorArguments: [REGISTRATION_FEE], + }); + console.log("Contract verified successfully"); + } catch (error: any) { + if (error.message.toLowerCase().includes("already verified")) { + console.log("Contract already verified"); + } else { + console.error("Verification failed:", error); + } + } + } + + // Output deployment info + console.log("\n=== Deployment Summary ==="); + console.log("Network:", network.name); + console.log("Contract Address:", address); + console.log("Registration Fee:", ethers.formatEther(REGISTRATION_FEE), "ETH"); + console.log("Owner:", (await ethers.getSigners())[0].address); + console.log("\nAdd this to your .env file:"); + console.log(`NEXT_PUBLIC_CONTRACT_ADDRESS=${address}`); +} + +main() + .then(() => process.exit(0)) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/test/BaseRegistry.test.ts b/test/BaseRegistry.test.ts new file mode 100644 index 0000000..bf54fc9 --- /dev/null +++ b/test/BaseRegistry.test.ts @@ -0,0 +1,469 @@ +import { expect } from "chai"; +import { ethers } from "hardhat"; +import { BaseRegistry } from "../typechain-types"; +import { SignerWithAddress } from "@nomicfoundation/hardhat-ethers/signers"; +import { time } from "@nomicfoundation/hardhat-network-helpers"; + +describe("BaseRegistry", function () { + let baseRegistry: BaseRegistry; + let owner: SignerWithAddress; + let user1: SignerWithAddress; + let user2: SignerWithAddress; + let user3: SignerWithAddress; + + const REGISTRATION_FEE = ethers.parseEther("0.001"); + const TEST_NAME = "testname"; + const TEST_DATA = "ipfs://QmTest123"; + const UPDATED_DATA = "ipfs://QmUpdated456"; + + beforeEach(async function () { + [owner, user1, user2, user3] = await ethers.getSigners(); + + const BaseRegistryFactory = await ethers.getContractFactory("BaseRegistry"); + baseRegistry = await BaseRegistryFactory.deploy(REGISTRATION_FEE); + await baseRegistry.waitForDeployment(); + }); + + describe("Deployment", function () { + it("Should set the correct owner", async function () { + expect(await baseRegistry.owner()).to.equal(owner.address); + }); + + it("Should set the correct registration fee", async function () { + expect(await baseRegistry.registrationFee()).to.equal(REGISTRATION_FEE); + }); + + it("Should not be paused initially", async function () { + expect(await baseRegistry.paused()).to.be.false; + }); + }); + + describe("Registration", function () { + it("Should allow registration with correct fee", async function () { + await expect( + baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }) + ) + .to.emit(baseRegistry, "Registered") + .withArgs(TEST_NAME, user1.address, TEST_DATA, await time.latest() + 1); + + const record = await baseRegistry.getRecord(TEST_NAME); + expect(record.owner).to.equal(user1.address); + expect(record.data).to.equal(TEST_DATA); + }); + + it("Should refund excess payment", async function () { + const excessAmount = ethers.parseEther("0.002"); + const initialBalance = await ethers.provider.getBalance(user1.address); + + const tx = await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: excessAmount, + }); + const receipt = await tx.wait(); + const gasUsed = receipt!.gasUsed * receipt!.gasPrice; + + const finalBalance = await ethers.provider.getBalance(user1.address); + const expectedBalance = initialBalance - REGISTRATION_FEE - gasUsed; + + expect(finalBalance).to.be.closeTo(expectedBalance, ethers.parseEther("0.0001")); + }); + + it("Should revert if name is empty", async function () { + await expect( + baseRegistry.connect(user1).register("", TEST_DATA, { + value: REGISTRATION_FEE, + }) + ).to.be.revertedWith("Name cannot be empty"); + }); + + it("Should revert if name is too long", async function () { + const longName = "a".repeat(33); + await expect( + baseRegistry.connect(user1).register(longName, TEST_DATA, { + value: REGISTRATION_FEE, + }) + ).to.be.revertedWith("Name too long"); + }); + + it("Should revert if data is too long", async function () { + const longData = "a".repeat(257); + await expect( + baseRegistry.connect(user1).register(TEST_NAME, longData, { + value: REGISTRATION_FEE, + }) + ).to.be.revertedWith("Data too long"); + }); + + it("Should revert if name is already registered", async function () { + await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }); + + await expect( + baseRegistry.connect(user2).register(TEST_NAME, "other data", { + value: REGISTRATION_FEE, + }) + ).to.be.revertedWith("Name already registered"); + }); + + it("Should revert if insufficient fee is paid", async function () { + const insufficientFee = ethers.parseEther("0.0005"); + await expect( + baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: insufficientFee, + }) + ).to.be.revertedWith("Insufficient fee"); + }); + + it("Should track owned names", async function () { + await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }); + + const ownedNames = await baseRegistry.getOwnedNames(user1.address); + expect(ownedNames).to.have.lengthOf(1); + expect(ownedNames[0]).to.equal(TEST_NAME); + }); + + it("Should allow multiple registrations by same user", async function () { + await baseRegistry.connect(user1).register("name1", TEST_DATA, { + value: REGISTRATION_FEE, + }); + await baseRegistry.connect(user1).register("name2", TEST_DATA, { + value: REGISTRATION_FEE, + }); + + const ownedNames = await baseRegistry.getOwnedNames(user1.address); + expect(ownedNames).to.have.lengthOf(2); + }); + }); + + describe("Update", function () { + beforeEach(async function () { + await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }); + }); + + it("Should allow owner to update data", async function () { + await expect(baseRegistry.connect(user1).update(TEST_NAME, UPDATED_DATA)) + .to.emit(baseRegistry, "Updated") + .withArgs(TEST_NAME, user1.address, UPDATED_DATA, await time.latest() + 1); + + const record = await baseRegistry.getRecord(TEST_NAME); + expect(record.data).to.equal(UPDATED_DATA); + }); + + it("Should update the updatedAt timestamp", async function () { + const recordBefore = await baseRegistry.getRecord(TEST_NAME); + + await time.increase(100); + + await baseRegistry.connect(user1).update(TEST_NAME, UPDATED_DATA); + + const recordAfter = await baseRegistry.getRecord(TEST_NAME); + expect(recordAfter.updatedAt).to.be.greaterThan(recordBefore.updatedAt); + }); + + it("Should revert if caller is not the owner", async function () { + await expect( + baseRegistry.connect(user2).update(TEST_NAME, UPDATED_DATA) + ).to.be.revertedWith("Not the owner"); + }); + + it("Should revert if data is too long", async function () { + const longData = "a".repeat(257); + await expect( + baseRegistry.connect(user1).update(TEST_NAME, longData) + ).to.be.revertedWith("Data too long"); + }); + }); + + describe("Transfer", function () { + beforeEach(async function () { + await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }); + }); + + it("Should allow owner to transfer name", async function () { + await expect(baseRegistry.connect(user1).transfer(TEST_NAME, user2.address)) + .to.emit(baseRegistry, "Transferred") + .withArgs(TEST_NAME, user1.address, user2.address, await time.latest() + 1); + + const record = await baseRegistry.getRecord(TEST_NAME); + expect(record.owner).to.equal(user2.address); + }); + + it("Should update owned names tracking", async function () { + await baseRegistry.connect(user1).transfer(TEST_NAME, user2.address); + + const user2Names = await baseRegistry.getOwnedNames(user2.address); + expect(user2Names).to.include(TEST_NAME); + }); + + it("Should revert if caller is not the owner", async function () { + await expect( + baseRegistry.connect(user2).transfer(TEST_NAME, user3.address) + ).to.be.revertedWith("Not the owner"); + }); + + it("Should revert if new owner is zero address", async function () { + await expect( + baseRegistry.connect(user1).transfer(TEST_NAME, ethers.ZeroAddress) + ).to.be.revertedWith("Invalid new owner"); + }); + + it("Should revert if transferring to self", async function () { + await expect( + baseRegistry.connect(user1).transfer(TEST_NAME, user1.address) + ).to.be.revertedWith("Already the owner"); + }); + + it("Should allow new owner to update after transfer", async function () { + await baseRegistry.connect(user1).transfer(TEST_NAME, user2.address); + + await expect( + baseRegistry.connect(user2).update(TEST_NAME, UPDATED_DATA) + ).to.not.be.reverted; + + const record = await baseRegistry.getRecord(TEST_NAME); + expect(record.data).to.equal(UPDATED_DATA); + }); + }); + + describe("Query Functions", function () { + it("Should return correct availability status", async function () { + expect(await baseRegistry.isAvailable(TEST_NAME)).to.be.true; + + await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }); + + expect(await baseRegistry.isAvailable(TEST_NAME)).to.be.false; + }); + + it("Should return empty record for unregistered name", async function () { + const record = await baseRegistry.getRecord("nonexistent"); + expect(record.owner).to.equal(ethers.ZeroAddress); + expect(record.data).to.equal(""); + expect(record.createdAt).to.equal(0); + expect(record.updatedAt).to.equal(0); + }); + + it("Should return correct record details", async function () { + const tx = await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }); + await tx.wait(); + + const record = await baseRegistry.getRecord(TEST_NAME); + expect(record.owner).to.equal(user1.address); + expect(record.data).to.equal(TEST_DATA); + expect(record.createdAt).to.be.greaterThan(0); + expect(record.updatedAt).to.equal(record.createdAt); + }); + }); + + describe("Admin Functions", function () { + describe("Fee Management", function () { + it("Should allow owner to update registration fee", async function () { + const newFee = ethers.parseEther("0.002"); + + await expect(baseRegistry.setRegistrationFee(newFee)) + .to.emit(baseRegistry, "FeeUpdated") + .withArgs(REGISTRATION_FEE, newFee); + + expect(await baseRegistry.registrationFee()).to.equal(newFee); + }); + + it("Should revert if non-owner tries to update fee", async function () { + const newFee = ethers.parseEther("0.002"); + + await expect( + baseRegistry.connect(user1).setRegistrationFee(newFee) + ).to.be.revertedWithCustomError(baseRegistry, "OwnableUnauthorizedAccount"); + }); + + it("Should allow setting fee to zero", async function () { + await baseRegistry.setRegistrationFee(0); + expect(await baseRegistry.registrationFee()).to.equal(0); + + await expect( + baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { value: 0 }) + ).to.not.be.reverted; + }); + }); + + describe("Pause Functionality", function () { + it("Should allow owner to pause contract", async function () { + await baseRegistry.pause(); + expect(await baseRegistry.paused()).to.be.true; + }); + + it("Should allow owner to unpause contract", async function () { + await baseRegistry.pause(); + await baseRegistry.unpause(); + expect(await baseRegistry.paused()).to.be.false; + }); + + it("Should revert if non-owner tries to pause", async function () { + await expect( + baseRegistry.connect(user1).pause() + ).to.be.revertedWithCustomError(baseRegistry, "OwnableUnauthorizedAccount"); + }); + + it("Should prevent registration when paused", async function () { + await baseRegistry.pause(); + + await expect( + baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }) + ).to.be.revertedWithCustomError(baseRegistry, "EnforcedPause"); + }); + + it("Should prevent updates when paused", async function () { + await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }); + + await baseRegistry.pause(); + + await expect( + baseRegistry.connect(user1).update(TEST_NAME, UPDATED_DATA) + ).to.be.revertedWithCustomError(baseRegistry, "EnforcedPause"); + }); + + it("Should prevent transfers when paused", async function () { + await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }); + + await baseRegistry.pause(); + + await expect( + baseRegistry.connect(user1).transfer(TEST_NAME, user2.address) + ).to.be.revertedWithCustomError(baseRegistry, "EnforcedPause"); + }); + + it("Should allow queries when paused", async function () { + await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }); + + await baseRegistry.pause(); + + await expect(baseRegistry.getRecord(TEST_NAME)).to.not.be.reverted; + await expect(baseRegistry.isAvailable(TEST_NAME)).to.not.be.reverted; + }); + }); + + describe("Withdrawal", function () { + beforeEach(async function () { + await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }); + }); + + it("Should allow owner to withdraw funds", async function () { + const contractBalance = await ethers.provider.getBalance( + await baseRegistry.getAddress() + ); + expect(contractBalance).to.equal(REGISTRATION_FEE); + + const initialBalance = await ethers.provider.getBalance(owner.address); + + await expect(baseRegistry.withdraw(owner.address)) + .to.emit(baseRegistry, "FundsWithdrawn") + .withArgs(owner.address, REGISTRATION_FEE); + + const finalBalance = await ethers.provider.getBalance(owner.address); + expect(finalBalance).to.be.greaterThan(initialBalance); + }); + + it("Should revert if non-owner tries to withdraw", async function () { + await expect( + baseRegistry.connect(user1).withdraw(user1.address) + ).to.be.revertedWithCustomError(baseRegistry, "OwnableUnauthorizedAccount"); + }); + + it("Should revert if withdrawal address is zero", async function () { + await expect( + baseRegistry.withdraw(ethers.ZeroAddress) + ).to.be.revertedWith("Invalid address"); + }); + + it("Should revert if no funds to withdraw", async function () { + await baseRegistry.withdraw(owner.address); + + await expect( + baseRegistry.withdraw(owner.address) + ).to.be.revertedWith("No funds to withdraw"); + }); + }); + }); + + describe("Gas Optimization", function () { + it("Should have reasonable gas cost for registration", async function () { + const tx = await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }); + const receipt = await tx.wait(); + + console.log("Registration gas used:", receipt!.gasUsed.toString()); + expect(receipt!.gasUsed).to.be.lessThan(200000n); + }); + + it("Should have reasonable gas cost for update", async function () { + await baseRegistry.connect(user1).register(TEST_NAME, TEST_DATA, { + value: REGISTRATION_FEE, + }); + + const tx = await baseRegistry.connect(user1).update(TEST_NAME, UPDATED_DATA); + const receipt = await tx.wait(); + + console.log("Update gas used:", receipt!.gasUsed.toString()); + expect(receipt!.gasUsed).to.be.lessThan(100000n); + }); + }); + + describe("Edge Cases", function () { + it("Should handle maximum length name", async function () { + const maxName = "a".repeat(32); + await expect( + baseRegistry.connect(user1).register(maxName, TEST_DATA, { + value: REGISTRATION_FEE, + }) + ).to.not.be.reverted; + }); + + it("Should handle maximum length data", async function () { + const maxData = "a".repeat(256); + await expect( + baseRegistry.connect(user1).register(TEST_NAME, maxData, { + value: REGISTRATION_FEE, + }) + ).to.not.be.reverted; + }); + + it("Should handle special characters in name", async function () { + const specialName = "test-name_123"; + await expect( + baseRegistry.connect(user1).register(specialName, TEST_DATA, { + value: REGISTRATION_FEE, + }) + ).to.not.be.reverted; + }); + + it("Should accept direct ETH transfers", async function () { + await expect( + owner.sendTransaction({ + to: await baseRegistry.getAddress(), + value: ethers.parseEther("1.0"), + }) + ).to.not.be.reverted; + }); + }); +});